One config, two lifecycles: making Scaffy’s AWS adapters swappable
In the first post I said Scaffy would let you swap out the pieces it runs on: use DynamoDB or bring your own database, Cognito or your own auth. That promise is easy to make and surprisingly easy to get wrong. This is the design I landed on, and the one idea that makes it work: every adapter is two programs that run at completely different times and never call each other.
The problem with “just make it pluggable”
A CMS like this touches AWS in two totally separate moments. Once, on my laptop, when I deploy: that’s when the DynamoDB table, the S3 bucket and the Cognito user pool get created. Then again, thousands of times, inside a Lambda on every request: reading from that table, signing an upload URL, checking a token. An adapter has to cover both, but the two moments know completely different things.
At deploy time I know things like which GitHub repo to build from and which origins to allow for CORS. I do not know the table’s name or the user pool’s ID, because those resources don’t exist yet; I’m in the middle of creating them. At request time it’s the exact opposite: the table name is sitting right there in an environment variable, but the CDK constructs that created it are long gone. Cram both into one object and you get an adapter that needs information it can’t have.
Two halves, named in one place
So each concern in scaffy.config.ts is a pair: a cdk half and a runtime half. The config file is the only place in the whole codebase that names a concrete adapter: everything downstream depends on interfaces, never on DynamoDbAdapter or CognitoAdapter directly. Change this file and you’ve swapped an implementation; nothing else moves.
// scaffy.config.ts
export default defineConfig({
repository: {
cdk: new DynamoDbCdkAdapter(),
runtime: (env) => new DynamoDbAdapter({
tableName: env.get('TABLE_NAME'),
}),
},
// ...auth, storage, build, each the same shape
}) Notice the asymmetry. cdk is an already-built instance: the infra stack will pick it up at deploy and call bind() on it. runtime isn’t an instance at all; it’s a recipe. It’s a function that, given an environment, returns the live adapter. It has to be a function, because the value it depends on, TABLE_NAME, doesn’t exist when this file is first read. You can’t hand someone a value you won’t have until later, so you hand them instructions for making it instead.
How the two halves actually meet
Here’s the part I like most: the cdk half and the runtime half never reference each other. They communicate through exactly one thing: a string. At deploy time the CDK adapter creates the table and injects its name into the Lambda as an env var. At cold start the runtime recipe reads that same env var back out.
That single string is the entire contract between the two lifecycles. The CDK side writes it; the runtime side reads it. Neither imports the other. That’s exactly why the same scaffy.config.ts can serve both cdk deploy and a running Lambda: they’re looking at the same file but using opposite halves of it.
Where recipes become real
The runtime recipes get called in one place, factory.ts, and the timing matters. It runs as module-level code, which means it executes once when a Lambda container cold-starts and is reused on every warm invocation after that. So the AWS SDK clients get built exactly once per container, the right place for that kind of thing.
// factory.ts: runs once per cold start
const env = new EnvironmentService();
export const databaseAdapter: DatabaseAdapter = config.repository.runtime(env);
export const authAdapter: AuthAdapter = config.auth.runtime(env); The exported types are the interfaces, not the concrete classes. Past this line, nothing in the backend can tell whether it’s talking to DynamoDB or something else, which is the whole point. And EnvironmentService.get() throws if a variable is missing, so a misconfigured deploy fails loudly at cold start with “Missing environment variable: TABLE_NAME” instead of quietly passing undefined into an adapter and breaking three layers down.
What it cost, and what’s next
It’s not free. Importing the config to read the runtime half also constructs the cdk instances, which drags aws-cdk-lib into the Lambda’s bundle even though it’s never used there. For now that’s a known follow-up: splitting the two entry points or constructing the cdk side lazily will fix it. But the core shape is in and tested end to end: config selects the adapters, the factory builds the runtime ones at cold start, and a handful of env-var strings bridge the two worlds. Next time I’ll get into the request path itself, including why a catch-all API Gateway route made me write my own router.