~/posts/lambda-to-api-service-cutover/

From Many Lambdas to One API Service

A practical migration pattern for consolidating handlers, preserving behavior, and moving production traffic with health checks and rollback intact.

awsbackendmigrationoperations

Serverless was not the villain

A collection of Lambda handlers can be the right way to get a backend into production.

Each function has an isolated deployment and a narrow entry point. Early in a product, that can keep infrastructure small and let a team ship route by route. The problem appears when the product becomes one connected domain but the runtime still treats every handler as a separate little application.

Shared authentication, database setup, error responses, CORS, logging, generated documentation, and deployment behavior begin to drift. Scheduled jobs and operator endpoints need their own exceptions. The migration question is not “containers or functions?” It is whether the current boundary still matches how the software changes and fails.

Inventory behavior before architecture

A safe cutover begins with the routes and jobs users already depend on.

I built an inventory of request methods, paths, authentication rules, payload contracts, response status, persistence behavior, scheduled work, and third-party side effects. That became the acceptance surface for the new runtime. A clean new router was not success if one quiet admin action or webhook disappeared.

The inventory also exposed inconsistencies that the old deployment model had hidden. Some handlers parsed requests differently, some returned different error shapes, and some mixed transport setup with domain work. Those differences had to be preserved intentionally or corrected with tests, not erased by assuming every function already followed one pattern.

Build one handler contract

The durable part of the migration was separating business behavior from Lambda transport types.

Routes were registered in one table with method, path, and handler metadata. A canonical handler contract accepted the application request context and returned a consistent response. Existing Lambda entry points could adapt cloud events into that contract while the container router adapted ordinary HTTP requests into the same shape.

This let the implementation move before the old runtime disappeared. Route coverage could be compared, tests could call handlers without constructing every cloud event, and OpenAPI documentation could derive from the same registry rather than another hand-maintained list.

Middleware made the new boundary real

A shared process only helps if shared concerns actually become consistent.

Authentication, request logging, CORS, panic recovery, error encoding, and database access moved into common middleware and initialization. Health endpoints separated process liveness from dependency readiness so deployment checks could tell “the binary is running” from “the service can safely receive traffic.”

Scheduled work received an explicit home rather than being smuggled through a request route. Startup and shutdown behavior became visible. A process that owns many routes must also own graceful failure: one panic should become a logged request failure, not a silent crash that leaves the load balancer guessing.

Cut over traffic, not confidence

The riskiest part was not building the image. It was changing which runtime received production requests.

The container path needed reproducible configuration, secrets, networking, database access, TLS termination, logs, and health checks before it could be considered equivalent. I kept rollback practical and verified representative routes through the same public boundary clients would use.

A runbook captured what to inspect when the service was healthy locally but unavailable behind the load balancer: target health, security groups, listener rules, DNS, certificates, environment, dependency readiness, and application logs. The migration became an operational procedure rather than a deployment command followed by hope.

Coherence was the actual win

Moving from many functions to one service did not make the system automatically simpler.

It traded per-function isolation for a coherent application boundary. That was useful because the codebase had reached the point where routes shared domain behavior, middleware, persistence, documentation, and operational needs. A smaller product or a set of truly independent event handlers might make the opposite trade.

The reusable lesson is to migrate around behavior and evidence. Build a route inventory, create one handler seam, make observability and health first-class, preserve scheduled work, and design the cutover and rollback before production traffic moves.