Before / After: Media Downloader Migration
Media downloader started as a standalone AWS CloudFormation + DynamoDB project. It was the origin project that Mantle was extracted from, and is now the most complete example of a fully migrated instance — 18 Lambdas, Aurora DSQL, EventBridge, SQS, and SNS, all driven by mantle build and mantle deploy.
Comparison
| Concern | Before (standalone) | After (Mantle instance) |
|---|---|---|
| Infra definition | ~1,200 lines of hand-written CloudFormation YAML | mantle.config.ts + sourced Terraform modules; mantle deploy --stage staging |
| Handler boilerplate | withPowertools(wrapApiHandler(...)) around every export; manual try/catch; raw {statusCode, body} returns | defineApiHandler({auth, operationName}) — no try/catch, no raw response objects |
| DB permissions | Manual IAM JSON policies per resource, maintained by hand | mantle generate permissions reads @RequiresTable decorators and generates Terraform + SQL grants |
| Observability | Manual logger.info() calls; metrics opt-in per handler | @mantleframework/observability auto-injected by define*Handler(); cold-start metrics, structured logging, X-Ray ADOT |
| Convention enforcement | 29-rule custom MCP server (ts-morph, 209 files) maintained in-repo | mantle check — 12 framework-aware rules + 5 subcommands, zero maintenance |
| Database | DynamoDB (no schema, no type safety) | Aurora DSQL + Drizzle ORM (typed queries, SQL migrations, IAM auth) |
| Build | Raw esbuild config per Lambda, updated manually | npx mantle build — discovers all handlers via filesystem routing |
| Deployment | cfn deploy / SAM CLI | npx mantle deploy --stage staging — OpenTofu plan + apply |
Handler: Before vs After
Before — raw handler with withPowertools wrapper (ADR-0006, pattern in use before Mantle extraction):
// src/lambdas/ListFiles/src/index.ts (pre-migration)
import { APIGatewayProxyHandler } from 'aws-lambda'
import { withPowertools, wrapOptionalAuthHandler } from '#lib/lambda/middleware'
import { buildValidatedResponse } from '#lib/lambda/responses'
import { getFilesForUser } from '#entities/queries'
import { FileStatus } from '#types/enums'
export const handler: APIGatewayProxyHandler = withPowertools(
wrapOptionalAuthHandler(async ({ event, context, userId, userStatus }) => {
try {
if (userStatus === 'anonymous') {
return buildValidatedResponse(context, 200, { contents: [], keyCount: 0 })
}
const files = await getFilesForUser(userId)
return buildValidatedResponse(context, 200, {
contents: files.filter(f => f.status === FileStatus.Downloaded),
keyCount: files.length
})
} catch (error) {
logger.error('ListFiles failed', { error })
return { statusCode: 500, body: JSON.stringify({ message: 'Internal error' }) }
}
})
)After — Mantle defineApiHandler (current, src/lambdas/api/files/index.get.ts):
import { buildValidatedResponse, UserStatus } from '@mantleframework/core'
import { defineApiHandler } from '@mantleframework/validation'
import { metrics, MetricUnit } from '@mantleframework/observability'
import { getFilesForUser } from '#entities/queries'
import { fileListResponseSchema } from '#types/api-schema'
import { FileStatus } from '#types/enums'
const api = defineApiHandler({ auth: 'authorizer', operationName: 'ListFiles' })
export const handler = api(async ({ event, context, userId, userStatus }) => {
if (userStatus === UserStatus.Anonymous) {
return buildValidatedResponse(context, 200, { contents: [], keyCount: 0 }, fileListResponseSchema)
}
const statusParam = event.queryStringParameters?.status || 'downloaded'
const files = (await getFilesForUser(userId as string))
.map(toFile)
.filter(f => statusParam === 'all' || f.status === FileStatus.Downloaded)
.sort((a, b) => new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime())
metrics.addMetric('FilesReturned', MetricUnit.Count, files.length)
return buildValidatedResponse(context, 200, { contents: files, keyCount: files.length }, fileListResponseSchema)
})What disappeared: try/catch, withPowertools, wrapOptionalAuthHandler, raw {statusCode, body} fallback, and manual logger import. What appeared: response schema validation on every return path, automatic observability, and operationName for X-Ray segment naming.
Migration Approach
The project was migrated incrementally by extracting the framework layer into @mantleframework/* packages while the instance continued shipping. Key steps:
- Extracted handler middleware into
@mantleframework/validation(defineApiHandler,defineSqsHandler, etc.) - Moved observability setup into
@mantleframework/observability(lazy Proxy singletons — no env var access at import time) - Replaced DynamoDB entities with Aurora DSQL + Drizzle schema; migrated data
- Replaced 1,200-line CloudFormation template with
mantle.config.ts+ sourced Terraform modules - Removed the 209-file custom MCP validation server; replaced with
mantle check(seedocs/wiki/Decisions/ADR-MCP-to-Mantle-Validation.md)
The result: the instance repo shrunk significantly while gaining type-safe infrastructure, automated permission generation, and framework-managed observability.