Skip to content

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

ConcernBefore (standalone)After (Mantle instance)
Infra definition~1,200 lines of hand-written CloudFormation YAMLmantle.config.ts + sourced Terraform modules; mantle deploy --stage staging
Handler boilerplatewithPowertools(wrapApiHandler(...)) around every export; manual try/catch; raw {statusCode, body} returnsdefineApiHandler({auth, operationName}) — no try/catch, no raw response objects
DB permissionsManual IAM JSON policies per resource, maintained by handmantle generate permissions reads @RequiresTable decorators and generates Terraform + SQL grants
ObservabilityManual logger.info() calls; metrics opt-in per handler@mantleframework/observability auto-injected by define*Handler(); cold-start metrics, structured logging, X-Ray ADOT
Convention enforcement29-rule custom MCP server (ts-morph, 209 files) maintained in-repomantle check — 12 framework-aware rules + 5 subcommands, zero maintenance
DatabaseDynamoDB (no schema, no type safety)Aurora DSQL + Drizzle ORM (typed queries, SQL migrations, IAM auth)
BuildRaw esbuild config per Lambda, updated manuallynpx mantle build — discovers all handlers via filesystem routing
Deploymentcfn deploy / SAM CLInpx 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):

typescript
// 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):

typescript
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:

  1. Extracted handler middleware into @mantleframework/validation (defineApiHandler, defineSqsHandler, etc.)
  2. Moved observability setup into @mantleframework/observability (lazy Proxy singletons — no env var access at import time)
  3. Replaced DynamoDB entities with Aurora DSQL + Drizzle schema; migrated data
  4. Replaced 1,200-line CloudFormation template with mantle.config.ts + sourced Terraform modules
  5. Removed the 209-file custom MCP validation server; replaced with mantle check (see docs/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.