Every AWS serverless backend I write starts the same way. A few Lambdas. A few DynamoDB tables. An AppSync API on top. Cognito for auth. Maybe RDS for the warehouse-y stuff. Then 200+ lines of CDK to wire it all together: same Stack boilerplate, same grantReadWriteData, same addEnvironment('TABLE_NAME', table.tableName), same dance, every time.
So I built simple-cdk. It's a thin convention layer on top of AWS CDK that turns a config file and a few folders into real CDK constructs. It's running production multi-tenant apps today. Here's what it is, what it isn't, and why the design choices matter.
What simple-cdk is
A thin convention layer on top of AWS CDK. You describe your app in one simple-cdk.config.ts, drop code into a few conventional folders, and built-in adapters (@simple-cdk/lambda, @simple-cdk/dynamodb, @simple-cdk/appsync, @simple-cdk/cognito, @simple-cdk/rds, @simple-cdk/outputs) turn those folders into real CDK constructs.
The engine does three things, in order:
-
discover: scans your folders to find handlers, models, and triggers. -
register: turns each one into a real CDK construct (alambda.Function, adynamodb.Table, etc.). -
wire: a final pass where adapters can look up each other's constructs and connect them (this is when the AppSync adapter wires Lambda data sources, when Cognito triggers attach to the user pool, etc.).
That's the whole engine. Nothing runs inside your AWS account. There's no agent watching your Lambdas, no SaaS dashboard you have to log into, no tracking of your deploys. The output is just a CloudFormation template, the same kind any other CDK app would produce. So your existing IAM auditing, your CloudFormation drift detection, and any deployment tooling you already use (CI, cdk deploy, GitHub Actions) all keep working unchanged.
What simple-cdk is not
It is not a framework like Amplify Gen 2 or SST. There's no managed environment, no live-Lambda dev loop, no "log in to our dashboard," no proprietary resource types in your CloudFormation.
It is also not opinionated about your folder structure beyond defaults. Each adapter exposes an opts.dir setting. Point it anywhere, swap the convention, or write your own discover hook that reads from somewhere else entirely.
And critically, adapters are not boilerplate generators. They don't write code into your repo. They scan a folder you already have and turn its contents into CDK constructs at synth time.
A complete app, in one file
// simple-cdk.config.ts
import { defineConfig } from '@simple-cdk/core';
import { lambdaAdapter } from '@simple-cdk/lambda';
import { dynamoDbAdapter } from '@simple-cdk/dynamodb';
import { appSyncAdapter } from '@simple-cdk/appsync';
export default defineConfig({
app: 'my-app',
defaultStage: 'dev',
stages: {
dev: { region: 'us-east-1', removalPolicy: 'destroy' },
prod: { region: 'us-east-1', removalPolicy: 'retain', logRetentionDays: 365 },
},
adapters: [
lambdaAdapter(),
dynamoDbAdapter(),
appSyncAdapter({
schemaFile: 'schema.graphql',
generateCrud: { models: 'all' },
}),
],
});
That's a working backend. Lambdas auto-discovered from backend/functions/, DynamoDB tables from backend/models/, an AppSync GraphQL API with auto-generated CRUD resolvers wired to those tables. No stacks, no constructs, no IAM dance.
npx simple-cdk deploy --stage dev
The adapter pattern (same every time)
Every built-in follows the same shape: a name plus up to three optional hooks.
interface Adapter {
name: string;
discover?(ctx): Resource[]; // find work (scan files, read config)
register?(ctx): void; // build CDK constructs
wire?(ctx): void; // look up other adapters' resources
}
There's nothing else to learn per service. The Cognito adapter and the RDS adapter and your own custom SQS adapter all use the same three hooks.
What adapters are for:
- Turning
backend/functions/foo/handler.tsinto alambda.Functionwithout you writing one - Exposing the resulting CDK construct via lookup helpers (
getLambdaFunction(ctx, 'foo')) so you can grab and tweak it - A single, predictable place to plug in cross-resource wiring (the
wirephase, which runs after every adapter'sregister)
What adapters are not for:
- Generating boilerplate code into your repo
- Hiding CDK behind a wrapper
- Forcing a specific folder layout
That last one matters most. The folder convention is a default, not a mandate.
simple-cdk and raw CDK compose in the same project
This is the part I most wanted to nail: simple-cdk doesn't replace CDK. It sits next to it.
-
ctx.stack(name)returns a realcdk.Stack. Attach any construct fromaws-cdk-libnext to anything an adapter created. - Built-in adapters expose lookup helpers (
getLambdaFunction,getDynamoTable,getUserPool,getAppSyncApi,getRdsInstance, etc.) that return underlying CDK constructs. Call any CDK method on them. - A custom adapter is just a plain object. Inside
registerorwire, write whatever raw CDK code you want.
You can also embed simple-cdk inside an existing CDK app. This is the feature I think is most underrated.
// bin/app.ts (your existing CDK entry)
import { App, Stack } from 'aws-cdk-lib';
import { Engine, defineConfig } from '@simple-cdk/core';
import { lambdaAdapter } from '@simple-cdk/lambda';
import { MyExistingNetworkStack } from '../lib/my-existing-network-stack.js';
const app = new App();
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' };
// Your hand-written stacks, unchanged.
new MyExistingNetworkStack(app, 'MyExistingNetworkStack', { env });
// A stack you want simple-cdk's adapters to register into.
const apiStack = new Stack(app, 'MyApiStack', { env });
const config = defineConfig({
app: 'my-app',
defaultStage: 'dev',
stages: { dev: { region: 'us-east-1' } },
adapters: [
lambdaAdapter({ stack: apiStack }), // place auto-discovered Lambdas in your Stack
],
});
await new Engine(config).synth({ cdkApp: app });
app.synth();
Two seams. Engine.synth({ cdkApp }) accepts a pre-built cdk.App. Every adapter takes an optional stack: to register its constructs into a Stack your hand-written code already owns. This means an existing CDK project can adopt simple-cdk for one slice (say, auto-discover handlers in backend/functions/) without rewriting anything else.
The corollary: there's no real "outgrowing" simple-cdk. If a service or pattern doesn't fit a built-in adapter, you write a small custom one ({ name, discover, register, wire }, no base classes) or drop raw CDK constructs into any stack the engine created. You don't have to leave, and even if you wanted to, the constructs are standard aws-cdk-lib so the cost is just lifting them into a hand-written file.
What a real production config looks like
Built-ins and custom adapters sit in the same list. The engine doesn't care which is which. This is the adapter list from a production multi-tenant healthcare backend running on simple-cdk:
adapters: [
myDataAdapter(), // custom: domain-shaped DynamoDB models with tenant-isolation
cognitoAdapter({ // built-in: user pool + 4 triggers + MFA + password policy
triggersDir: 'backend/auth/triggers',
mfa: 'optional',
passwordPolicy: { minLength: 12, requireSymbols: true /* ... */ },
}),
myAuthExtrasAdapter(), // custom: identity pool, SES, pre-token-generation grants
myStorageAdapter(), // custom: S3 buckets
myFunctionsAdapter(), // custom: like @simple-cdk/lambda but with per-domain metadata
myWarehouseAdapter(), // custom: RDS + VPC + DynamoDB stream consumer + EventBridge
myApiAdapter(), // custom: uses buildApi() directly to drop resolvers into
// nested stacks (CFN 500-resource limit per stack)
outputsAdapter({ // built-in: SSM parameter the frontend reads at boot
parameterName: `/my-app/${stage}/aws-config`,
collect: (ctx) => ({
region: ctx.config.stageConfig.region,
userPoolId: getUserPool(ctx).userPoolId,
userPoolClientId: getUserPoolClient(ctx).userPoolClientId,
// ... plus identity pool id, AppSync URL, bucket names from custom adapters
}),
}),
],
A few things worth highlighting:
-
The custom API adapter uses
buildApifrom@simple-cdk/appsyncdirectly instead of the high-levelappSyncAdapter(). When the high-level adapter doesn't fit, you drop one level deeper and keep the CRUD pipeline.buildApi,attachCrudResolvers, andattachManualResolversare all exported for exactly this case. -
outputsAdapteris the seam between custom and built-in. It pulls the Cognito user pool from@simple-cdk/cognito, the AppSync API from a custom adapter, and bucket names from another custom adapter, then bundles everything into one SSM parameter the frontend reads at boot. - There is no "production mode" of simple-cdk. Same engine, same adapter contract for the minimal example and the multi-adapter prod shape. You opt into complexity when you need it, only for the parts that need it.
simple-cdk vs raw CDK vs Amplify vs SST
Honest comparisons. Pick the one that fits. They're not mutually exclusive forever.
vs Raw CDK
Pick raw CDK when your topology is unusual (deeply custom multi-stack graphs, multi-account fanout, non-serverless workloads), or when you want zero external dependencies beyond aws-cdk-lib.
Pick simple-cdk when you find yourself writing the same Lambda + DynamoDB + AppSync wiring in every project, or when you want filesystem conventions for handlers and models without giving up the CDK escape hatch.
You don't even have to pick one or the other now. simple-cdk produces normal CDK constructs and accepts an existing cdk.App. An existing CDK project can adopt simple-cdk for one slice without rewriting anything else.
vs AWS Amplify (Gen 2)
Pick Amplify when you want a fully managed full-stack framework that hosts frontend + backend together, you're fine living inside Amplify's deploy and runtime model, and you want first-class frontend codegen for Auth and Data.
Pick simple-cdk when you want Amplify's "drop a file in a folder, get a resource" feel but on plain CDK: no Amplify CLI, no Amplify Hosting, no Amplify-specific resource types in your CloudFormation. If Amplify Gen 2's restrictions don't fit (custom AppSync resolvers, cross-stack constructs, your own VPC), simple-cdk is closer to the metal.
vs SST
Pick SST when you want their developer experience: sst dev live Lambda, the SST console, sst.dev hosted features, Resource Linking. SST owns the deploy story end-to-end.
Pick simple-cdk when you'd rather have less in the loop. simple-cdk just runs cdk deploy. No daemon, no console, no SST-managed resources.
Both Amplify and SST produce CDK/CloudFormation underneath, so migrating between any of these is mostly renaming, not rewriting.
Pros, cons, when to use
Pros. Thin layer (engine is a few hundred lines and knows nothing about specific AWS services). Output is CloudFormation, no vendor lock-in beyond CDK itself. Mixable with raw CDK and embeddable inside an existing CDK app. Same three-hook adapter pattern across every service. Multi-stage out of the box. TypeScript-first, fully typed.
Cons. Built-ins target serverless: Lambda, DynamoDB, AppSync, Cognito, RDS, and an outputs SSM parameter ship today. Anything else, you write the adapter. No frontend or hosting. Small ecosystem (one maintainer). Convention-light: you still write a config file, this isn't "click a button and a backend appears."
When to use it: TypeScript serverless backend on AWS (Lambda + DynamoDB + GraphQL or REST), team already uses CDK and is tired of repeating wiring, multi-stage deploys (dev / staging / prod / sandboxes), you want filesystem conventions without giving up raw CDK as an escape hatch.
When not to use it: Non-serverless workloads (ECS/EKS/EC2-heavy), you want a fully managed full-stack platform (use Amplify or SST), or your team has zero appetite for a small dependency with one maintainer.
Try it
mkdir my-app && cd my-app
npx simple-cdk@latest init
npx cdk bootstrap
npx simple-cdk deploy --stage dev
init walks you through app name, region, default stage, and which adapters to include, then writes a working simple-cdk.config.ts and the backend/ folders.
Links
- Source: github.com/pujaaan/simple-cdk
- Comparison page (deeper side-by-side): docs/Comparison.md
- Architecture (engine internals + lifecycle): docs/Architecture.md
- Embedding in an existing CDK app: docs/Adopting.md
- Issues & discussions: github.com/pujaaan/simple-cdk/issues
If you've used simple-cdk in anger, drop a note in the repo discussions. I'd love to know what's worked and what hasn't.












