Skip to content
Sutando
Esc
↑↓navigate↵open⌘Jpreview

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:

  1. Start by converting the simplest models (no complex relations)
  2. Move on to models with hasMany / belongsTo relations
  3. 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 execute
  • withGraphFetched('[posts.comments]') becomes with('posts.comments')
  • insert() / patch() / deleteById() become Active Record style save() / 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 SoftDeletes plugin (compose(Model, SoftDeletes)) and get deleted_at handling out of the box
  • Built-in scopes: Define reusable query filters with scope methods
  • 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.