A Modern Alternative to Objection.js: Migrating to Sutando
Objection.js has been a popular ORM in the Node.js ecosystem for years. Built on top of Knex.js, it offered a flexible, SQL-friendly approach to database…
Objection.js has been a popular ORM in the Node.js ecosystem for years. Built on top of Knex.js, it offered a flexible, SQL-friendly approach to database operations. But with the original maintainer stepping away and TypeScript support lagging, many teams are looking for a modern alternative. This guide walks through migrating from Objection.js to Sutando.
Why Move Away from Objection.js?
Objection.js remains a capable library, but several factors make migration worth considering:
Maintenance Status
In a GitHub discussion, the original author Sami Koskimäki announced he would no longer actively maintain the project. Key points:
- No active maintainer: The project has a single bus factor, and that person has stepped away
- No new features: Only community forks are receiving updates
- Slow releases: Months between patch releases, if any
TypeScript Support
Objection.js was designed before TypeScript was widely adopted. The type system works, but it requires manual type declarations and doesn’t provide the seamless inference that modern TypeScript-first ORMs offer.
No Built-in Migrations
Objection.js relies entirely on Knex for migrations. While Knex is solid, you’re managing two separate tools with different APIs and mental models.
Why Sutando?
Sutando is a modern Active Record ORM for Node.js, inspired by Laravel’s Eloquent. It addresses the pain points Objection.js users face:
| Feature | Objection.js | Sutando |
|---|---|---|
| Pattern | Query Builder + ORM | Active Record |
| TypeScript | Manual types | First-class TS support |
| Migrations | Via Knex (separate) | Built-in schema builder |
| Maintenance | Stalled | Active |
| Relations | withGraphFetched |
with(), hasMany(), belongsTo() |
| Soft Deletes | Manual | Built-in |
| Hooks | $beforeInsert etc. |
creating, saved etc. |
| Scopes | Manual | Built-in |
Concept Mapping: Objection.js → Sutando
Models
Objection.js:
import { Model } from 'objection';
import knex from 'knex';
const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
});
Model.knex(db);
class User extends Model {
static tableName = 'users';
static jsonSchema = {
type: 'object',
required: ['name', 'email'],
properties: {
id: { type: 'integer' },
name: { type: 'string', minLength: 1, maxLength: 255 },
email: { type: 'string', format: 'email' },
},
};
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: 'users.id', to: 'posts.user_id' },
},
};
}
Sutando:
import { sutando, Model } from 'sutando';
sutando.addConnection({
client: 'pg',
connection: process.env.DATABASE_URL,
});
class User extends Model {
protected table = 'users';
relationPosts() {
return this.hasMany(Post);
}
}
Queries
Objection.js:
// Simple query
const users = await User.query().where('age', '>', 18);
// With relations
const user = await User.query()
.withGraphFetched('[posts.comments]')
.findById(1);
// Insert
const user = await User.query().insert({ name: 'Alice', email: 'alice@example.com' });
// Update
await User.query().patch({ name: 'Bob' }).where('id', 1);
// Delete
await User.query().deleteById(1);
Sutando:
// Simple query
const users = await User.query().where('age', '>', 18).get();
// With relations
const user = await User.query().with('posts.comments').find(1);
// Insert
const user = new User({ name: 'Alice', email: 'alice@example.com' });
await user.save();
// Update
const user = await User.query().find(1);
user.name = 'Bob';
await user.save();
// Delete
const user = await User.query().find(1);
await user.delete();
Relations
Objection.js uses relationMappings with a declarative object syntax:
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: 'users.id', to: 'posts.user_id' },
},
profile: {
relation: Model.BelongsToOneRelation,
modelClass: Profile,
join: { from: 'users.profile_id', to: 'profiles.id' },
},
};
Sutando uses method-based definitions, similar to Eloquent:
relationPosts() {
return this.hasMany(Post);
}
relationProfile() {
return this.belongsTo(Profile);
}
Transactions
Objection.js:
await User.transaction(async (trx) => {
const user = await User.query(trx).insert({ name: 'Alice' });
await Post.query(trx).insert({ title: 'Hello', user_id: user.id });
});
Sutando:
const db = sutando.connection();
await db.transaction(async (trx) => {
const user = new User({ name: 'Alice' });
await user.save({ client: trx });
const post = new Post({ title: 'Hello', user_id: user.id });
await post.save({ client: trx });
});
Hooks / Events
Objection.js:
class User extends Model {
static tableName = 'users';
$beforeInsert() {
this.created_at = new Date().toISOString();
}
$beforeUpdate() {
this.updated_at = new Date().toISOString();
}
}
Sutando:
import { Model } from 'sutando';
class User extends Model {
protected table = 'users';
static booted() {
this.creating(model => {
model.created_at = new Date();
});
this.updating(model => {
model.updated_at = new Date();
});
}
}
Step-by-Step Migration Guide
Step 1: Install Sutando
npm install sutando
Step 2: Set Up the Database Connection
Create a db.ts file:
import { sutando } from 'sutando';
sutando.addConnection({
client: 'pg',
connection: process.env.DATABASE_URL,
});
export default sutando;
Step 3: Convert Models Gradually
You don’t need to migrate everything at once. Both ORMs can coexist in the same project:
- Start by converting the simplest models (no complex relations)
- Move on to models with
hasMany/belongsTorelations - Finally, convert models with complex graph operations
Step 4: Migrate Migrations
If you’re using Knex migrations, Sutando’s schema builder is compatible:
const { Migration } = require('sutando');
module.exports = class extends Migration {
async up(schema) {
await schema.createTable('users', (table) => {
table.increments('id');
table.string('name');
table.string('email').unique();
table.timestamps();
});
}
async down(schema) {
await schema.dropTableIfExists('users');
}
};
Step 5: Replace Queries
Go through your codebase and replace Objection.js queries with Sutando equivalents. The main changes:
Model.query()in Objection returns a query builder; in Sutando, you chain.get()or.first()to executewithGraphFetched('[posts.comments]')becomeswith('posts.comments')insert()/patch()/deleteById()become Active Record stylesave()/delete()
What You Gain After Migration
- Active Record pattern: No more separate query builder and model — operate on model instances directly
- Built-in soft deletes: Apply the
SoftDeletesplugin (compose(Model, SoftDeletes)) and getdeleted_athandling out of the box - Built-in scopes: Define reusable query filters with
scopemethods - First-class TypeScript: Type inference works naturally with class-based models
- Eloquent-style API: If you’ve ever used Laravel, the learning curve is nearly zero
- Active maintenance: Regular releases and new features
Conclusion
Objection.js served the Node.js community well, but its maintenance status and TypeScript limitations make it a liability for long-term projects. Sutando offers a familiar, well-maintained alternative with an Active Record pattern that reduces boilerplate and improves developer experience.
The migration can be done incrementally — both ORMs can coexist while you transition model by model. Start with a single model today and see the difference for yourself.
👉 Get started with Sutando or run npm install sutando to try it out.