Lift: Type-Safe Serverless Framework for AWS Lambda in Go
Lift is a production-ready framework for building AWS Lambda functions in Go. It provides automatic error handling, logging, observability, and multi-tenant support while reducing boilerplate code.
Why Lift?
Use Lift when you need:
- ✅ Production-ready Lambda functions with minimal cold start overhead
- ✅ Type-safe handlers with compile-time validation
- ✅ Built-in error handling, logging, and distributed tracing
- ✅ Multi-tenant support with automatic tenant isolation
- ✅ Zero-configuration middleware for auth, CORS, rate limiting
- ❌ Don't use for: Non-Lambda deployments, custom runtimes, or non-Go languages
Quick Start
// This is a recommended pattern for Lambda functions in Go
// It provides automatic error handling, validation, and observability
package main
import (
"github.com/aws/aws-lambda-go/lambda"
"github.com/pay-theory/lift/pkg/lift"
"github.com/pay-theory/lift/pkg/middleware"
)
// Type-safe request/response with automatic validation
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Age int `json:"age" validate:"min=0,max=120"`
}
type UserResponse struct {
UserID string `json:"user_id"`
TenantID string `json:"tenant_id,omitempty"`
}
func main() {
app := lift.New()
// Configure the app
config := &lift.Config{
MaxRequestSize: 5 * 1024 * 1024, // 5MB
Timeout: 29, // 29 seconds
LogLevel: "INFO",
}
app.WithConfig(config)
// Add essential middleware for production
app.Use(middleware.RequestID()) // Distributed tracing
app.Use(middleware.Logger()) // Structured logging
app.Use(middleware.Recover()) // Panic recovery
// Type-safe handler - recommended over raw handlers
app.POST("/users", lift.SimpleHandler(func(ctx *lift.Context, req CreateUserRequest) (UserResponse, error) {
// Automatic: parsing, validation, error handling
return UserResponse{
UserID: "user_123",
TenantID: ctx.TenantID(), // Multi-tenant support
}, nil
}))
// For Lambda deployment
lambda.Start(app.HandleRequest)
}
// Alternative: Basic handler pattern
func CreateUser(ctx *lift.Context) error {
var req CreateUserRequest
if err := ctx.ParseRequest(&req); err != nil {
return lift.ValidationError(err.Error())
}
ctx.Status(201)
return ctx.JSON(UserResponse{
UserID: "user_123",
TenantID: ctx.TenantID(),
})
}
Core Concepts
Context
The Context is Lift's unified interface for Lambda functions. This is important because it provides all request data, response methods, and service clients in one place.
Example:
// Use lift.Context as your handler parameter
func HandlePayment(ctx *lift.Context) error {
// Parse request with validation
var payment PaymentRequest
if err := ctx.ParseRequest(&payment); err != nil {
return lift.ValidationError("Invalid request")
}
// Access multi-tenant context
userID := ctx.UserID()
tenantID := ctx.TenantID()
// Structured logging
ctx.Logger.Info("Processing payment",
"user_id", userID,
"amount", payment.Amount)
// Return JSON response
ctx.Status(200)
return ctx.JSON(PaymentResponse{
ID: "payment_123",
Status: "completed",
})
}
// The Context abstracts all event sources (API Gateway, SQS, S3, etc.)
Type-Safe Handlers
Lift uses Go generics for compile-time type safety. This prevents runtime errors and provides IDE autocomplete.
Example:
// Type-safe handler with automatic validation
app.POST("/orders", lift.SimpleHandler(func(ctx *lift.Context, req OrderRequest) (OrderResponse, error) {
// Request is already parsed and validated
// Return type is enforced at compile time
return processOrder(req)
}))
// Standard handler with manual parsing
app.POST("/orders", func(ctx *lift.Context) error {
var req OrderRequest
if err := ctx.ParseRequest(&req); err != nil {
return lift.ValidationError(err.Error())
}
// Process and return response
return ctx.JSON(response)
})
Installation
For New Lambda Projects (Recommended)
# This method is preferred for new serverless Go applications
go mod init myservice
go get github.com/pay-theory/lift/pkg/lift
go get github.com/pay-theory/lift/pkg/middleware
For Existing Lambda Projects
# Use this when migrating from raw Lambda handlers
go get github.com/pay-theory/lift/pkg/lift
# See Migration Guide below for step-by-step conversion
Common Patterns
Pattern: Request Validation
When to use: Every API endpoint accepting user input
Why: Prevents invalid data from reaching business logic
// Validation tags with automatic enforcement
type PaymentRequest struct {
Amount int64 `json:"amount" validate:"required,min=100"`
Currency string `json:"currency" validate:"required,oneof=USD EUR"`
Email string `json:"email" validate:"required,email"`
}
app.POST("/payments", lift.SimpleHandler(func(ctx *lift.Context, req PaymentRequest) (PaymentResponse, error) {
// Request is guaranteed valid here
return processPayment(req)
}))
// Manual validation in standard handler
app.POST("/payments", func(ctx *lift.Context) error {
var req PaymentRequest
if err := ctx.ParseRequest(&req); err != nil {
return lift.ValidationError(err.Error())
}
// Additional business logic validation if needed
if req.Amount < 100 {
return lift.ValidationError("amount too small")
}
return ctx.JSON(response)
})
Pattern: Multi-Tenant Isolation
When to use: SaaS applications with tenant data isolation
Why: Ensures data security and compliance
// Use Context tenant helpers
func GetUserOrders(ctx *lift.Context) error {
tenantID := ctx.TenantID() // Automatic from JWT/headers
userID := ctx.UserID()
orders := db.Query("SELECT * FROM orders WHERE tenant_id = ? AND user_id = ?",
tenantID, userID)
return ctx.JSON(orders)
}
// Configure app for multi-tenant support
config := &lift.Config{
RequireTenantID: true,
}
app.WithConfig(config)
Pattern: Middleware Composition
When to use: Cross-cutting concerns (auth, logging, rate limiting)
Why: Separation of concerns and reusability
// Middleware chains for different route groups
api := app.Group("/api")
// JWT authentication
jwtMiddleware, _ := middleware.JWTAuth(middleware.JWTConfig{
Secret: os.Getenv("JWT_SECRET"),
})
api.Use(jwtMiddleware)
// Rate limiting
rateLimiter, _ := middleware.UserRateLimitWithLimited(100, time.Hour)
api.Use(rateLimiter)
admin := api.Group("/admin")
admin.Use(middleware.RequireRole("admin")) // Additional admin check
// Routes automatically inherit middleware
api.GET("/orders", GetOrders) // Has auth + rate limit
admin.GET("/users", ListUsers) // Has auth + rate limit + admin
API Reference
lift.New() *App
Purpose: Creates a new Lift application instance
When to use: Once at the start of your Lambda function
When NOT to use: Don't create multiple apps per Lambda
// Create and configure app
app := lift.New()
config := &lift.Config{
MaxRequestSize: 10 * 1024 * 1024, // 10MB
MaxResponseSize: 6 * 1024 * 1024, // 6MB (Lambda limit)
Timeout: 29, // 29 seconds
LogLevel: "INFO",
MetricsEnabled: true,
}
app.WithConfig(config)
app.Use(middleware ...Middleware)
Purpose: Adds middleware to all routes
When to use: For cross-cutting concerns like logging, auth
When NOT to use: For route-specific logic
// Standard middleware stack
app.Use(
middleware.RequestID(), // First: generates request ID
middleware.Logger(), // Second: logs with request ID
middleware.Recover(), // Third: catches panics
)
// Order matters: RequestID must come before Logger
ctx.ParseRequest(dest interface{}) error
Purpose: Parses and validates request body into struct
When to use: For all POST/PUT/PATCH endpoints
When NOT to use: GET requests (use ctx.Query instead)
// Safe request parsing
var req UpdateUserRequest
if err := ctx.ParseRequest(&req); err != nil {
return lift.ValidationError(err.Error())
}
// req is now validated and type-safe
Error Handling
Built-in error constructors:
// 401 Unauthorized
return lift.Unauthorized("authentication required")
// 403 Forbidden
return lift.AuthorizationError("insufficient permissions")
// 404 Not Found
return lift.NotFound("user not found")
// 422 Validation Error
return lift.ValidationError("invalid email format")
// Custom errors
return lift.NewLiftError("PAYMENT_FAILED", "Payment processing failed", 500)
Best Practices
- Use type-safe handlers when possible - Prevents runtime errors and improves code clarity
- Use Context methods instead of raw Lambda events - Better portability and testing
- Use struct tag validation - Cleaner than manual validation
- Add standard middleware - RequestID, Logger, Recover
- Never log sensitive data - No passwords, tokens, or PII in logs
- Use middleware for cross-cutting concerns - Don't repeat auth/logging in handlers
Integration Examples
With DynamoDB (via DynamORM)
Lift provides standardized DynamoDB table structures that work seamlessly with DynamORM. All tables use a consistent pk
/sk
pattern with GSIs defined through struct tags.
For detailed DynamORM integration, see: DynamORM Integration Guide
// Define your model with BOTH DynamORM and DynamoDB tags
type User struct {
// Keys must have both tags
PK string `dynamorm:"pk" ` // user#{user_id}
SK string `dynamorm:"sk" ` // user#{user_id}
// GSI fields need both tags too
Email string `dynamorm:"index:email-index,pk" ` // GSI for email lookup
TenantID string `dynamorm:"index:tenant-index,pk" ` // GSI for tenant queries
// DynamORM handles marshaling internally
UserID string `json:"user_id" `
Name string `json:"name" `
TTL int64 `json:"ttl,omitempty" dynamorm:"ttl"`
}
func GetUser(ctx *lift.Context) error {
userID := ctx.Param("id")
tenantID := ctx.TenantID() // Multi-tenant isolation
// Query using composite key for tenant isolation
user, err := dynamorm.Get[User](ctx.Context, db).
WithTable(os.Getenv("DYNAMODB_TABLE")).
WithPK(fmt.Sprintf("tenant#%s", tenantID)).
WithSK(fmt.Sprintf("user#%s", userID)).
Execute()
if err == dynamorm.ErrNotFound {
return lift.NotFound("user not found")
}
if err != nil {
return lift.NewLiftError("DATABASE_ERROR", "Failed to get user", 500)
}
return ctx.JSON(user)
}
With SQS Events
// Lift handles multiple event sources
app.SQS("process-orders", func(ctx *lift.Context) error {
// SQS message is in ctx.Request.Body
var order Order
if err := ctx.ParseRequest(&order); err != nil {
return err // Message returns to queue
}
ctx.Logger.Info("Processing order", "order_id", order.ID)
return processOrder(order)
})
With EventBridge Scheduled Events
// Same Context interface for all event types
app.EventBridge("daily-report", func(ctx *lift.Context) error {
ctx.Logger.Info("Running scheduled job")
// Use same patterns as HTTP handlers
return runScheduledJob(ctx)
})
Troubleshooting
Error: "json: cannot unmarshal string into Go struct field"
Cause: Request body doesn't match struct types
Solution: Check struct tags and request payload
// Correct: Matching types
type Request struct {
Count int `json:"count"` // Expects number
Name string `json:"name"` // Expects string
}
// If client sends: {"count": "5"} - this will fail
// Fix: Ensure client sends: {"count": 5}
Error: "context deadline exceeded"
Cause: Handler took longer than configured timeout
Solution: Increase timeout or optimize handler
// Solution 1: Increase app timeout (must be less than Lambda timeout)
config := &lift.Config{
Timeout: 300, // 5 minutes in seconds
}
app.WithConfig(config)
// Solution 2: Add timeout awareness
func LongRunningHandler(ctx *lift.Context) error {
deadline, _ := ctx.Deadline()
for {
select {
case <-ctx.Done():
return lift.NewLiftError("TIMEOUT", "operation timed out", 504)
default:
// Do work in chunks
}
}
}
Error: "no handler found for path"
Cause: Route not registered or wrong HTTP method
Solution: Check route registration and method
// Common mistake: Wrong method
app.GET("/users", GetUsers) // Registered GET
// But client calls POST /users - will get 404
// Fix: Register correct method
app.POST("/users", CreateUser)
Migration Guide
From Raw Lambda Handlers
// Old pattern (raw Lambda handler):
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Manual JSON parsing
var req Request
json.Unmarshal([]byte(request.Body), &req)
// Manual validation
if req.Name == "" {
return events.APIGatewayProxyResponse{
StatusCode: 400,
Body: `{"error": "name required"}`,
}, nil
}
// Manual response building
resp, _ := json.Marshal(Response{ID: "123"})
return events.APIGatewayProxyResponse{
StatusCode: 200,
Body: string(resp),
Headers: map[string]string{
"Content-Type": "application/json",
},
}, nil
}
// New pattern with Lift:
func main() {
app := lift.New()
app.Use(middleware.Logger())
app.POST("/", lift.SimpleHandler(func(ctx *lift.Context, req Request) (Response, error) {
// Automatic parsing, validation, and response formatting
return Response{ID: "123"}, nil
}))
lambda.Start(app.HandleRequest)
}
// Benefits: Type safety, automatic validation, consistent errors, logging, tracing
From Gin/Echo/Fiber
// Old pattern (Gin on Lambda):
func setupRouter() *gin.Engine {
r := gin.Default()
r.POST("/users", func(c *gin.Context) {
var req Request
c.ShouldBindJSON(&req)
c.JSON(200, Response{})
})
return r
}
// New pattern with Lift (Lambda-optimized):
func main() {
app := lift.New()
app.POST("/users", lift.SimpleHandler(func(ctx *lift.Context, req Request) (Response, error) {
return Response{}, nil
}))
lambda.Start(app.HandleRequest)
}
// Benefits: Faster cold starts, native Lambda integration, smaller binary
Lift is designed for Lambda environments:
- Minimal cold start overhead (typically under 15ms)
- Low memory footprint
- Efficient request routing
- Built-in connection pooling for AWS services
- Automatic resource cleanup
Compared to traditional web frameworks:
- Optimized for Lambda's execution model
- No unnecessary HTTP server overhead
- Native Lambda event support without adapters
Security Features
Built-in Security
- Input Validation: Automatic via struct tags
- Error Sanitization: Never leak internal errors
- Panic Recovery: Graceful error responses
- Request ID: Trace requests across services
- CORS: Configurable CORS middleware
- Rate Limiting: Multiple strategies available
JWT Authentication
// Built-in JWT validation
jwtMiddleware, _ := middleware.JWTAuth(middleware.JWTConfig{
Secret: os.Getenv("JWT_SECRET"),
})
app.Use(jwtMiddleware)
// Access claims in handlers
func SecureHandler(ctx *lift.Context) error {
userID := ctx.UserID() // From JWT claims
// Claims are validated and available
}
Testing Support
// Lift includes testing utilities
import lifttesting "github.com/pay-theory/lift/pkg/testing"
func TestHandler(t *testing.T) {
// Create test context
ctx := lifttesting.NewTestContext(
lifttesting.WithMethod("POST"),
lifttesting.WithPath("/users"),
lifttesting.WithBody(`{"name": "test"}`),
lifttesting.WithHeaders(map[string]string{
"Authorization": "Bearer token",
}),
)
// Execute handler
err := CreateUser(ctx)
assert.NoError(t, err)
// Check response
assert.Equal(t, 200, ctx.Response.StatusCode)
}
Production Checklist
Before deploying to production:
- Add standard middleware (RequestID, Logger, Recover)
- Configure appropriate timeouts (less than Lambda timeout)
- Set up structured logging with log levels
- Enable distributed tracing
- Add health check endpoint
- Configure CORS if needed
- Set up monitoring alerts
- Test error scenarios
- Load test with expected traffic
Contributing
This is a Pay Theory internal project. See our development documentation:
- Architecture:
docs/architecture/
- Development Guide:
docs/development/
- API Patterns:
docs/patterns/
License
Apache License - See LICENSE file for details
About This Codebase
This entire codebase was written 100% by AI code generation, guided by the development team at Pay Theory. The framework represents a collaboration between human architectural vision and AI implementation capabilities, demonstrating the potential of AI-assisted software development for creating production-ready systems.