Portable serverless handlers are function handlers written in standard application formats instead of provider-specific request and response shapes.
For small teams, portability matters because the first serverless decision is rarely the last one. A webhook may start on one platform, move into a VM, become a worker, run behind a container, or later move into Kubernetes. If the function code is tied too tightly to one provider's handler signature, event format, SDK, deployment model, or logging system, a simple move can become a rewrite.
Raff Technologies designed Raff Functions around standard handlers: Python FastAPI / ASGI, Node.js standard http, TypeScript, JavaScript Web Fetch, Go net/http, and a Dockerfile escape hatch for other runtimes. The goal is simple: write code that looks like normal application code, not a proprietary function format.
This guide explains what serverless lock-in looks like, how portable handlers reduce migration risk, when provider-specific code is still acceptable, and how small teams should structure serverless functions so they can move between Raff Functions, VMs, containers, and other infrastructure without rebuilding the application from scratch.
If you are still deciding whether a workload belongs on Functions or a VM, read Serverless Functions vs VMs: What Small Teams Should Choose first.
Serverless lock-in usually starts small
Serverless lock-in does not usually start as a big architectural decision.

It starts with one handler:
def lambda_handler(event, context):
...
or one platform-specific request object, one proprietary event payload, one storage binding, one logging API, one deployment file, or one local development workaround.
That may be fine for a prototype. But if the function becomes important, those small choices become migration work.
Lock-in can appear in several layers:
| Layer | What lock-in looks like |
|---|
| Handler format | Function depends on a provider-specific event and response structure |
| Runtime assumptions | Code only works inside one hosted runtime |
| Trigger format | HTTP, cron, queue, and storage events use non-portable shapes |
| SDK usage | Business logic calls provider SDKs directly everywhere |
| Deployment model | Build, config, and routing exist only in one platform |
| Logs and metrics | Observability depends on provider-specific tools |
| Secrets and bindings | Credentials are injected in a way that cannot move |
| Local development | Function cannot run locally without emulating the provider |
The risk is not that a team can never move. The risk is that moving becomes expensive at exactly the moment the workload is already important.
A portable handler does not eliminate all platform work. It reduces the amount of business logic tied to one platform.
The quick decision framework
Use this table before writing a new serverless function.
| Situation | Better pattern | Why |
|---|
| HTTP webhook endpoint | Standard HTTP handler | Easier to test, move, and run locally |
| Small public API | Standard framework route | Keeps code close to normal app code |
| Cron job | Normal function plus explicit input | Avoids platform-only event assumptions |
| Object storage event | Thin adapter around standard logic | Keeps business logic portable |
| Existing Lambda import | Convert to standard handler when possible | Reduces future migration work |
| Language outside common runtimes | Dockerfile handler | Keeps runtime choice flexible |
| Provider-specific feature is essential | Isolate provider code | Keep lock-in at the edge |
| Business logic calls cloud APIs | Wrap those calls behind small modules | Avoids scattering platform dependency |
| Function may move to VM later | Keep environment and config explicit | Makes server migration easier |
| Prototype with no reuse expected | Provider-specific handler is acceptable | Speed may matter more than portability |
A useful rule:
Put provider-specific code at the edge. Keep business logic in portable modules.
That one rule prevents many future rewrites.
Standard HTTP handlers are easier to move
HTTP is the most portable shape for many serverless workloads.
A webhook receiver, small API, callback endpoint, form handler, or internal tool can usually be expressed as normal HTTP code:
Request
↓
Validate input
↓
Call business logic
↓
Return response
That maps naturally to common application frameworks.
Raff Functions supports standard handler formats such as Python FastAPI / ASGI, Node.js standard http, JavaScript Web Fetch, TypeScript, and Go net/http. That means the code can look like normal server code instead of a provider-specific event processor.
For example, the portable shape is:
HTTP request in
HTTP response out
The less portable shape is:
Provider event object in
Provider response object out
The difference matters when the team wants to:
- Run the same handler locally
- Move the route to a VM
- Put the route into a container
- Test it with normal HTTP tooling
- Share code with the main app
- Avoid provider-specific mock events
- Keep junior developers productive
- Reduce migration complexity later
A standard HTTP handler is not only cleaner. It is easier to reason about.
Sometimes a function must receive a provider-specific event.

That is common for:
- Storage events
- Queue events
- Scheduled events
- Legacy Lambda events
- Provider-specific webhook formats
- Internal platform triggers
The key is to use a thin adapter.
A thin adapter converts the platform event into a normal internal input. Then the business logic runs independently.
Bad pattern:
Provider event
↓
Business logic mixed with provider event fields
↓
Provider response
Better pattern:
Provider event
↓
Adapter extracts normal input
↓
Portable business logic
↓
Adapter returns platform response
Example structure:
handler.py # provider/platform edge
service.py # portable business logic
repository.py # database access
storage.py # object storage wrapper
This way, a migration only changes handler.py and maybe a small binding layer. The core logic remains the same.
That matters for SaaS teams because many early functions become business-critical later. A billing webhook, export processor, file converter, or AI task should not be impossible to move because all logic is tangled with one event object.
Portable functions need portable configuration
Code is only one part of portability.
A function also needs configuration:
- Environment variables
- Secrets
- Memory
- Timeout
- Scaling limits
- Trigger type
- Database connection
- Object storage bucket
- API keys
- Region
- Log level
- Feature flags
If configuration exists only inside one provider dashboard, moving the function becomes harder.
A portable approach:
Code reads named environment variables
Secrets are documented
Bindings are explicit
Timeout and memory are recorded
Trigger behavior is described
Local development has an example .env
For example:
DATABASE_URL
OBJECT_STORAGE_BUCKET
WEBHOOK_SECRET
LOG_LEVEL
MAX_BATCH_SIZE
Those names can be reused across Raff Functions, local development, Docker containers, and VMs.
Raff Functions supports environment variables, masked secrets, and bindings to Raff Managed Databases and Raff Object Storage with scoped credentials. Use those features, but still keep the function's required configuration clear in the repository.
A function that cannot be configured outside the dashboard is not fully portable.
Avoid spreading provider SDK calls through business logic
Provider SDKs are sometimes necessary.
A function may need to read object storage, call a queue, fetch secrets, publish events, or write logs. That is fine. The problem is scattering provider-specific SDK calls across the entire codebase.
Better pattern:
business logic
↓
small interface/module
↓
provider-specific SDK
Example:
storage.save_report(file)
database.update_job_status(job_id, status)
notifications.send_email(user_id, template)
The business logic should not need to know which platform stores the file or how the credential is injected.
This helps when moving between:
- Raff Functions
- Raff VM
- Docker containers
- Kubernetes jobs
- local development
- another function platform
The internal wrapper can change. The business workflow stays readable.
For a small team, this is not overengineering. It is a lightweight boundary that keeps migrations possible.
Lock-in is not always bad, but it should be intentional
Not every provider-specific feature is a mistake.
Sometimes the best decision is to use the platform feature because it saves time, improves reliability, or gives a capability the team would not build itself.
Provider-specific code may be acceptable when:
- The function is small and temporary
- The feature saves significant engineering time
- The team has no realistic migration need
- The workload is internal only
- The provider feature is central to the product
- The lock-in is documented and accepted
- The code is isolated enough to replace later
The problem is accidental lock-in.
Accidental lock-in happens when a team writes code quickly without noticing that the function now depends on one provider's event structure, response shape, secret model, deployment workflow, and logs.
A useful decision:
If lock-in saves time, document it.
If lock-in touches business logic, isolate it.
If lock-in is accidental, remove it early.
Portability is not an ideology. It is a risk control.
Raff's portable-handler model
Raff Functions is built around standard handlers rather than a Raff-specific function signature.

Supported patterns include:
- Python FastAPI / ASGI
- Node.js standard
http
- TypeScript
- JavaScript Web Fetch
- Go
net/http
- Dockerfile for other languages or custom runtimes
That matters because the same application patterns are familiar outside the functions platform.
A Python FastAPI handler can be easier to run locally. A Node http handler is a standard server shape. A Web Fetch handler follows a common request/response model. A Go net/http handler maps to normal Go server code. A Dockerfile escape hatch means the team is not blocked if the runtime needs something custom.
Raff also supports importing AWS Lambda functions and converting them toward a cleaner, portable handler with a reviewable diff. That is useful when the team already has Lambda code but wants to reduce proprietary handler dependency over time.
The goal is not to pretend all platforms are identical. The goal is to keep your function code close to standard application code.
What a portable function structure looks like
A portable function should separate four concerns:
- Handler
- Business logic
- Data access
- Platform bindings
Example structure:
functions/
billing-webhook/
main.py
service.py
database.py
storage.py
README.md
The files have different jobs:
| File | Responsibility |
|---|
main.py | HTTP route or function entry |
service.py | Business logic |
database.py | Durable state access |
storage.py | File/artifact access |
README.md | Required env vars, triggers, timeout, owner |
The handler should stay thin:
Receive request
↓
Validate input
↓
Call service function
↓
Return response
The service should not know whether it is running on Raff Functions, a VM, or a container. It should receive normal inputs and return normal outputs.
That structure makes the function easier to test and easier to move.
Portable handlers make local testing easier
Local testing is one of the most practical benefits of portability.
A function written as normal HTTP code can often be tested with:
- curl
- browser requests
- Postman
- normal unit tests
- framework test clients
- Docker
- local environment variables
A function tied to a proprietary event object often needs mock event JSON, context objects, and provider-specific local emulation.
That extra complexity slows development.
A portable test path looks like this:
Run handler locally
↓
Send normal HTTP request
↓
Assert response
↓
Test business logic separately
This is especially useful for:
- Webhooks
- API endpoints
- form handlers
- image processors
- report generators
- scheduled jobs with explicit inputs
- file-processing functions
A small team should not need a full cloud emulator to test simple business logic.
Portability helps VM and Kubernetes migration later
Some functions should stay functions. Others may move later.
A webhook might start as a function, then become part of the main app. A batch function might grow into a worker VM. A file processor might later run as a Kubernetes job. A small API might become a full service.
Portable handlers make those changes easier.
A migration path might look like this:
Raff Function
↓
Docker container
↓
Raff VM
↓
Kubernetes workload
or:
Raff VM route
↓
Raff Function
↓
Worker VM
The direction can change. What matters is whether the code is trapped in one platform model.
If the function's core logic is just a normal module, the team can move the runtime without rewriting the product behavior.
For broader infrastructure decisions, read Single VM vs Multi-VM Architecture for SaaS Apps and Kubernetes vs Docker Compose for Small Teams.
HTTP handlers are the easiest to make portable. Non-HTTP triggers need more care.
Common trigger types include:
- Cron schedules
- Object storage events
- One-off future runs
- Queue events
- Webhooks
- Database events
- Internal events
A portable trigger design should define a normal input shape for business logic.
For example, an object storage event can be converted into:
{
"bucket": "uploads",
"key": "incoming/report.csv",
"account_id": "acct_123"
}
Then the service function can process that object without knowing the original provider event shape.
A cron job can pass a simple input:
{
"job_name": "daily-report",
"run_date": "2026-07-20"
}
A one-off job can pass:
{
"job_id": "job_123",
"input_uri": "s3://bucket/input.csv"
}
The platform event can be different. The internal job input should be stable.
This is the difference between portable business logic and provider-dependent glue.
Database and storage bindings should stay narrow
Functions often need access to databases and object storage.
That access should be narrow and explicit.
A portable function should not depend on one giant credential that gives broad access to everything. It should define exactly what it needs:
- Which database
- Which schema or table
- Which object storage bucket
- Which prefix
- Which read/write permissions
- Which API token
- Which environment variables
- Which tenant or account scope
Raff Functions can bind to Raff Managed Databases with scoped per-function credentials and to Raff Object Storage with scoped S3 keys per bucket. That helps reduce the blast radius of each function.
But portability still depends on how the application code uses those bindings.
Good pattern:
Function reads DATABASE_URL and STORAGE_BUCKET
Business logic calls database.py and storage.py
Credentials stay outside code
Bad pattern:
Business logic depends on provider-specific binding object everywhere
Use the platform binding to inject access. Use your own small module to keep the code clean.
Observability should be portable enough to understand
Logs and metrics are part of the function's operating model.
Raff Functions includes logs and metrics for every invocation, including live log tail, search and level filters, 14-day retention, count, p50, p95, and error rate per function.
That is useful, but the log content should still be understandable outside one platform.
Use structured, safe log messages:
webhook_received provider=stripe event_id=evt_123
job_started job_id=job_456 type=image_resize
job_completed job_id=job_456 duration_ms=842
job_failed job_id=job_456 reason=object_not_found
Avoid logs that only make sense inside one provider's context object.
Good logs should help the team debug the function whether it runs on Raff Functions, a VM, or another runtime.
Log the business event, not only the platform event.
A practical portability checklist
Use this checklist before shipping a production serverless function.
The checklist is not about making every function cloud-agnostic forever. It is about avoiding unnecessary rewrites.
Common mistakes to avoid
Putting business logic inside a proprietary handler
A provider event object should not shape the whole codebase.
Parse the event at the edge, then call portable business logic.
Depending on a provider SDK everywhere
SDKs are fine at the boundary.
Do not scatter provider-specific calls across the entire workflow.
Hiding configuration in the dashboard
Environment variables, secrets, memory, timeout, and triggers should be documented in the repository.
Making local testing too hard
If a simple webhook or API handler requires a full cloud emulator to test, the function may be too tightly coupled.
Portability does not mean refusing useful features.
Use platform features intentionally and isolate them when possible.
Forgetting data portability
Code is not the only lock-in.
Data, files, logs, secrets, and deployment workflow also affect portability.
Splitting too early
Do not turn one simple function into too many abstraction layers.
Use simple boundaries: handler, service, database, storage, configuration.
Portable handlers keep future options open
Serverless is most useful when it makes work easier to run, not when it traps the team inside one provider's event model.
Use portable handlers for webhooks, small APIs, cron jobs, storage events, and batch tasks when the function may grow, move, or become business-critical. Keep provider-specific code at the edge. Keep business logic in normal modules. Store durable state in a database and files in object storage. Document configuration, triggers, timeouts, and secrets.
For Raff teams, the natural pattern is to use Raff Functions for standard handlers, Raff VM when the workload needs a full server, Raff Managed Databases for durable records, and Raff Object Storage for files and artifacts.
That gives the team flexibility without sacrificing a clean production operating model.