Somewhere around the sixth Fastify service in the same monorepo, I noticed that every new resource followed the same ritual: write a Drizzle schema, write a Zod validator that mirrors it, write the Fastify route schemas, write the OpenAPI annotations, and then spend twenty minutes making sure the four definitions don't drift apart. I was copy-pasting structure across layers and calling it engineering.
Define started as a script that read a YAML file and spat out TypeScript. It grew into something more interesting: a type-safe resource DSL that generates Drizzle schemas, Fastify validators, and OpenAPI 3.1 specs from a single source definition. Here's why I chose code generation over building a framework — and what the tradeoffs actually are.
The framework trap
The natural instinct when you see repetition is to abstract it. Build a base class. Write a "resource" abstraction that handles schemas, validation, and routing in one place. I've done this before, and I've regretted it every time.
Frameworks couple your application to the framework's opinion about how things should work. When the opinion matches your needs, it's invisible. When it doesn't, you're fighting the abstraction instead of solving the problem. And the mismatch always comes — usually three months in, when a requirement arrives that the framework's author (which is you, six months ago) didn't anticipate.
The specific problem with a resource framework in this context is that Drizzle, Zod, and Fastify have different type systems. A Drizzle column has a database type, a default value, and nullability constraints. A Zod schema has runtime validation, transformation, and error messages. A Fastify route schema has HTTP semantics — path parameters, query strings, request bodies. Unifying these into one abstraction means either losing expressiveness in each layer or building a meta-type-system that's harder to learn than the three originals.
"A framework says 'here's how your code should look.' A code generator says 'here's what your code should look like,' and then gets out of the way. The difference is ownership — generated code is your code."
How Define works
You write a resource definition using a TypeScript-native DSL. It looks like this:
import { resource, field } from "@define/core";
export const User = resource("user", {
id: field.uuid().primaryKey(),
email: field.string().unique().email(),
name: field.string().min(1).max(255),
role: field.enum(["admin", "member", "viewer"]).default("viewer"),
createdAt: field.timestamp().defaultNow(),
});From this single definition, Define generates three files:
A Drizzle schema with the correct column types, constraints, and indexes. The UUID primary key becomes uuid().primaryKey().defaultRandom(), the enum becomes a Postgres enum type, and the timestamp gets a defaultNow().
A Zod validator with runtime validation that matches the schema constraints. The email field gets z.string().email(), the name gets min/max length checks, and the role gets a literal union type. Create and update schemas are generated separately — the update schema makes all fields optional and strips the auto-generated ones.
An OpenAPI 3.1 spec with request/response schemas that reference the Zod types. Path parameters, query filters, and pagination are inferred from the resource definition. The spec is valid, complete, and can be served directly from a /docs endpoint.
The AST compiler
Under the hood, Define doesn't do string concatenation. It builds a TypeScript AST and emits code using the TypeScript compiler API. This means the generated code has correct imports, proper formatting, and valid type annotations. It's not a template engine — it's a compiler that happens to target TypeScript source files instead of JavaScript bytecode.
The AST approach solves a class of problems that template engines can't: import deduplication, circular reference detection, and context-aware code generation. If two resources reference each other, the compiler can detect the cycle and generate the correct import order. A Handlebars template can't do that.
Generated code is debuggable code
The strongest argument for code generation over frameworks is debuggability. When something goes wrong in a framework, you read the framework's source code. When something goes wrong in generated code, you read your code — because that's what it is. It's right there in your repo, version-controlled, diffable, and greppable.
If the generator produces something wrong, you can edit the output directly, fix the issue, and file a bug. You're never blocked on a framework release. You're never stuck reading a stack trace that passes through twelve layers of abstraction you didn't write.
This is the practical difference between convention and generation. A convention says "structure your code this way." If you deviate, the framework breaks. A generator says "here's a starting point." If you deviate, the code still works — it's just code.
When you shouldn't do this
Code generation is not always the right call. If your resource definitions are genuinely unique — different shapes, different validation rules, different access patterns — then generating them from a shared schema just adds a build step without removing any real duplication.
It works best when you have many resources that share structural conventions but differ in their field definitions. CRUD-heavy services, admin panels, data pipelines — anywhere you're writing the same scaffolding with different column names.
For Supergate and Pulse, Define generates about 70% of the boilerplate. The remaining 30% is custom business logic that no generator should touch. That's a healthy ratio. If the generator is producing 100% of your code, you've built a framework and called it something else.