ZenStack V3: The Perfect Prisma ORM Alternative

Prisma won the hearts of many developers with its excellent developer experience - the elegant schema language, the intuitive query API, and the unmatched type-safety. However, as time went by, its focus shifted and innovation in the ORM space has slowed down considerably. It's true that many successful OSS projects struggle to meet the ever-growing demands, but you'll be surprised to find that many seemingly fundamental features that have been requested for years are still missing today, such as:
- Define type of content of Json field #3219
- Support for Polymorphic Associations #1644
- Soft deletes (e.g. deleted_at) #3398
As a new challenger in this space, ZenStack aspires to be the spiritual successor of Prisma, but with a light-weighted architecture, a richer feature set, well-thought-out extensibility, and an easy-to-contribute codebase. Further more, its being essentially compatible with Prisma means you can have a smooth transition from existing Prisma projects.
A bit of historyโ
ZenStack started its journey as a power pack to Prisma ORM back in late 2022. It extended Prisma's schema language with more attributes and functions, and enhances PrismaClient at runtime with more features. The most notable enhancement is the introduction of access policies, which allows you to declaratively define fine-grained access rules in the schema which are transparently enforced at runtime.
As we went deeper along the path with v1 and v2, we've felt increasingly constrained by Prisma's intrinsic limitations. We decided to make a bold change in v3 - to build our own ORM engine (on top of the awesome Kysely) and migrate away from Prisma. To ensure an easy migration path, we've made several important compatibility commitments:
- The schema language stays compatible with (in fact a super set of) Prisma Schema Language (PSL).
- The resulting database schema remains unchanged so no data migration is needed.
- The query API stays compatible with Prisma Client API.
- Existing migration records continue to work without changes.
Dual API from a single schemaโ
PrismaClient's API is pleasant to use, but when you go beyond simple queries, you'll have to resort to raw SQL queries. Prisma introduced the TypedSQL feature to mitigate this problem. Unfortunately it only solved half of the problem (having the query results typed), but you still have to write SQL, which is quite a drop in developer experience.
ZenStack v3, thanks to the power of Kysely, offers a dual API design. You can continue to use the elegant high-level ORM queries like:
const users = await db.user.findMany({
where: { age: { gt: 18 } },
include: { posts: true },
});
Or, when your needs outgrow its power, you can seamlessly switch to Kysely's type-safe fluent query builder API:
const users = await db
.$qb
.selectFrom('User')
.where('age', '>', 18)
.leftJoin('Post', 'Post.authorId', 'User.id')
.select(['User.*', 'Post.title'])
.execute();
We believe for most applications, you'll never ever need to write a single line of SQL anymore.
Battery includedโ
ZenStack aims to be a battery-included ORM and solve many common data modeling/query problems in a coherent package. Here are just a few examples showing how far it's come to achieve this goal.
Built-in access controlโ
Every serious application needs non-trivial authorization. Wouldn't it be great if you can consolidate all access rules in the data model and never need to worry about them at query time? Here you go:
Schema:
model Post {
id Int @id
title String
published Boolean
author User @relation(fields: [authorId], references: [id])
authorId Int
@@deny('all', auth() == null) // deny anonymous access
@@allow('all', auth() == author) // owner has full access
@@allow('read', published) // published posts are publicly readable
}
Query:
async function handleRequest(req: Request) {
// get the validated current user from your auth system
const user = await getCurrentUser(req);
// create a user-bound ORM client
const userDb = db.$setAuth(user);
// query with access policies automatically enforced
const posts = await userDb.post.findMany();
}
Strongly typed JSONโ
JSON columns are getting more and more popular in relational databases. Getting them typed is straightforward.
Schema:
model User {
id Int @id
profile Profile @json
}
type Profile {
age Int
bio String
}
Query:
const user = await db.user.findFirstOrThrow();
console.log(user.profile.age); // strongly typed
Polymorphic modelsโ
Ever felt the need of modeling an inheritance hierarchy in the database? Polymorphic models come to the rescue.
Schema:
model Content {
id Int @id
title String
type String
// marks this model to be a polymorphic base with its
// concrete type designated by the "type" field
@@delegate(type)
}
model Post extends Content {
content String
}
model Image extends Content {
data Bytes
}
Query:
// a query with base automatically includes sub-model fields
const content = await db.content.findFirstOrThrow();
// the returned type is a discriminated union
if (content.type === 'Post') {
// typed narrowed to `Post`
console.log(content.content);
} else if (content.type === 'Image') {
// typed narrowed to `Image`
console.log(content.data);
}
These are just some of the existing features. More cool stuff like soft deletes, audit trail, etc. will be added in the future.
Extensible from ground upโ
ZenStack ORM involves three main pillars - the schema language, the CLI, and the ORM runtime. All three pillars are designed with extensibility in mind.
-
The schema language allows you to freely add custom attributes and functions to extend its semantics. E.g., you can add an
@encryptedattribute to mark fields that need encryption.attribute @encrypted()
model User {
id Int @id
email String @unique @encrypted
} -
The ORM runtime allows you to add plugins that can intercept queries at different levels and modify their payload or entire behavior. For the example above, you can create a plugin that recognize the
@encryptedattribute and transparently encrypt/decrypt field values during writes/reads.const dbWithEncryption = db.$use(
new EncryptionPlugin({
algorithm: 'AES-256-CBC',
key: process.env.ENCRYPTION_KEY,
})
); -
The CLI allows you to add generators to emit custom artifacts based on the schema. Think of generating an ERD diagram, or a GraphQL schema.
plugin erd {
provider = './plugins/erd-generator'
output = "./erd-diagram.md"
}
As a matter of fact, the access control feature mentioned earlier is entirely implemented with these extension points as a plugin package. The potential is limitless.
Simpler, smaller footprintโ
ZenStack is a monorepo, 100% TypeScript project - no native binaries, no WASM modules. It keeps things lean and reduces deployment footprint. As a quick comparison, for a minimal project that uses Postgres database, the "node_modules" size difference (with npm install --omit=dev) is quite significant:
| ORM | "node_modules" Size |
|---|---|
| Prisma | 224 MB |
| ZenStack V3 | 33 MB |
A simpler code base also makes it easier for contributors to navigate and contribute.
Beyond ORMโ
A feature-rich ORM can enable some very interesting new use cases. For example, since the ORM is equipped with built-in access control, it can be directly mapped to a service that offers a full-fledged data query API without writing any code. You effectively get a self-hosted Backend-as-a-Service but without any vendor lock-in. Check out the Query-as-a-Service documentation if you're interested.
Further more, frontend query hooks (based on TanStack Query) can be automatically derived and they work seamlessly with the backend service.
All summed up, the project's goal is to be the data layer of modern full-stack applications. Kill boilerplates, eliminate redundancy, and let your data model drive as many things as possible.
Conclusionโ
ZenStack v3 is currently in Beta, and a production-ready version will land soon. If you're interested in trying out migrating an existing Prisma project, you can find a more thorough guide here. Make sure to join us in Discord and we'd love to hear your feedback!
