Skip to main content

ORM Patterns

Hysteria ORM supports two distinct patterns for working with your database models:

  1. Active Record Pattern - Static methods on model classes (recommended)
  2. Repository Pattern - Using getModelManager() for dependency injection

Active Record Pattern

The Active Record pattern is the recommended approach for most applications. It provides a clean, intuitive API where you call static methods directly on your model classes.

How it Works

In the Active Record pattern, your model classes have static methods that handle database operations. These methods internally use a ModelManager instance but abstract away the complexity.

Example Usage

import { Model, column } from 'hysteria-orm';

class User extends Model {
@column.integer({ primaryKey: true })
declare id: number;

@column()
declare name: string;

@column()
declare email: string;
}

// Active Record Pattern - Static methods on the model
const user = await User.insert({ name: 'John', email: 'john@example.com' });
const users = await User.find({ where: { isActive: true } });
const userById = await User.findOneByPrimaryKey(1);
const query = User.query().where('name', 'like', '%John%').many();

Available Static Methods

MethodDescriptionExample
Model.insert(data)Insert a new recordUser.insert({ name: 'John' })
Model.insertMany(data[])Insert multiple recordsUser.insertMany([{ name: 'John' }, { name: 'Jane' }])
Model.find(options)Find multiple recordsUser.find({ where: { isActive: true } })
Model.findOne(options)Find a single recordUser.findOne({ where: { email: 'john@example.com' } })
Model.findBy(column, value)Find by specific columnUser.findBy('email', 'john@example.com')
Model.findOneBy(column, value)Find one by specific columnUser.findOneBy('email', 'john@example.com')
Model.findOneByPrimaryKey(id)Find by primary keyUser.findOneByPrimaryKey(1)
Model.update(data, options)Update recordsUser.update({ name: 'Johnny' }, { where: { id: 1 } })
Model.delete(options)Delete recordsUser.delete({ where: { isActive: false } })
Model.query()Get query builderUser.query().where('age', '>', 18).many()
Model.all()Get all recordsUser.all()
Model.count(options)Count recordsUser.count({ where: { isActive: true } })
Model.exists(options)Check if records existUser.exists({ where: { email: 'john@example.com' } })

Benefits

  • Clean API: Simple, intuitive method calls
  • Type Safety: Full TypeScript support with proper typing
  • Model Awareness: Automatic handling of decorators, relations, and hooks
  • Less Boilerplate: No need to manage ModelManager instances
  • Familiar: Similar to other popular ORMs like TypeORM
  • Model Embedding Support: Can be used with embedded models for even cleaner syntax

Model Embedding with Active Record

Model embedding allows you to attach models directly to a SQL data source instance, providing an even cleaner API that combines the benefits of both patterns:

import { sql } from 'hysteria-orm';
import { User } from './models/User';
import { Post } from './models/Post';

// Connect with embedded models
const sqlInstance = await sql.connect({
models: {
user: User,
post: Post,
},
});

// Access models directly through the data source (Active Record + Embedding)
const user = await sqlInstance.user.insert({ name: 'John', email: 'john@example.com' });
const users = await sqlInstance.user.find({ where: { isActive: true } });
const posts = await sqlInstance.post.query().where('published', true).many();

Benefits of Model Embedding

  • Cleaner Syntax: Access models as properties on the data source
  • Type Safety: Full TypeScript support with AugmentedSqlDataSource<T>
  • Connection Context: Models are tied to specific connections
  • Prisma-like Experience: Similar to Prisma's client model access
  • All Active Record Benefits: Still get all the benefits of the Active Record pattern

When to Use Model Embedding

Model embedding is perfect when you want:

  • Cleaner API: sqlInstance.user.insert() vs User.insert()
  • Connection-specific Models: Different models for different connections
  • Service Architecture: Pass the data source instance to services
  • Testing: Easy to mock the entire data source with embedded models
// Service that accepts embedded models
class UserService {
constructor(private sql: AugmentedSqlDataSource<{ user: typeof User }>) {}

async createUser(userData: Partial<User>) {
return await this.sql.user.insert(userData);
}

async findActiveUsers() {
return await this.sql.user.find({ where: { isActive: true } });
}
}

// Usage
const sqlInstance = await sql.connect({ models: { user: User } });
const userService = new UserService(sqlInstance);
const users = await userService.findActiveUsers();

Repository Pattern (getModelManager)

The Repository pattern provides more control and is useful for dependency injection, testing, or when you need explicit control over the data source connection.

How it Works

Instead of using static methods, you get a ModelManager instance from the data source and use its methods directly.

Example Usage

import { sql, Model, column } from 'hysteria-orm';

class User extends Model {
@column.integer({ primaryKey: true })
declare id: number;

@column()
declare name: string;

@column()
declare email: string;
}

// Repository Pattern - Using getModelManager
const userManager = sql.getModelManager(User);

const user = await userManager.insert({ name: 'John', email: 'john@example.com' });
const users = await userManager.find({ where: { isActive: true } });
const userById = await userManager.findOneByPrimaryKey(1);
const query = userManager.query().where('name', 'like', '%John%').many();

When to Use Repository Pattern

The Repository pattern is useful when you need:

  1. Dependency Injection: Pass the manager to services or controllers
  2. Testing: Mock the manager for unit tests
  3. Multiple Connections: Use different data sources for the same model
  4. Explicit Control: Want to manage the data source connection explicitly

Example with Dependency Injection

// Service class that accepts a ModelManager
class UserService {
constructor(private userManager: ModelManager<User>) {}

async createUser(userData: Partial<User>) {
return await this.userManager.insert(userData);
}

async findActiveUsers() {
return await this.userManager.find({
where: { isActive: true }
});
}
}

// Usage
const userManager = sql.getModelManager(User);
const userService = new UserService(userManager);
const users = await userService.findActiveUsers();

Example with Custom Connection

// Using a secondary connection
const secondarySql = await sql.connectToSecondarySource({
type: 'postgres',
host: 'read-replica.example.com',
// ... other config
});

const readOnlyUserManager = secondarySql.getModelManager(User);
const users = await readOnlyUserManager.find({ where: { isActive: true } });

Pattern Comparison

FeatureActive RecordActive Record + EmbeddingRepository Pattern
API Simplicity✅ Very simple✅ Cleanest syntax⚠️ More verbose
Type Safety✅ Full support✅ Full support✅ Full support
Dependency Injection❌ Not suitable✅ Perfect for services✅ Perfect
Testing⚠️ Harder to mock✅ Easy to mock✅ Easy to mock
Multiple Connections⚠️ Limited✅ Full control✅ Full control
Model Awareness✅ Automatic✅ Automatic✅ Automatic
Hooks & Relations✅ Full support✅ Full support✅ Full support
Learning Curve✅ Easy✅ Easy⚠️ Moderate
Connection Context❌ Global only✅ Per-connection✅ Per-connection

Best Practices

Use Active Record When:

  • Building typical web applications
  • You want the simplest API
  • You're using the default database connection
  • You don't need complex dependency injection

Use Active Record + Model Embedding When:

  • Building service-oriented applications
  • You want the cleanest possible API (sqlInstance.user.insert())
  • You need connection-specific models
  • You want dependency injection with clean syntax
  • You're building microservices or modular applications
  • You need easy testing with mockable data sources

Use Repository Pattern When:

  • Building enterprise applications with complex architecture
  • You need fine-grained control over ModelManager instances
  • You're working with multiple database connections
  • You want explicit control over data source management
  • You need to pass individual managers to different services

Migration Between Patterns

You can easily switch between patterns as your application grows:

// Start with Active Record
const user = await User.insert({ name: 'John' });

// Upgrade to Active Record + Model Embedding for cleaner services
const sqlInstance = await sql.connect({ models: { user: User } });
class UserService {
constructor(private sql: AugmentedSqlDataSource<{ user: typeof User }>) {}

async createUser(data: Partial<User>) {
return await this.sql.user.insert(data);
}
}

// Or use Repository Pattern for fine-grained control
class UserService {
constructor(private userManager: ModelManager<User>) {}

async createUser(data: Partial<User>) {
return await this.userManager.insert(data);
}
}

All patterns use the same underlying ModelManager class, so the functionality is identical. The choice is primarily about API design and architectural preferences.

  1. Start with Active Record for simple applications
  2. Upgrade to Active Record + Model Embedding when you need services and dependency injection
  3. Use Repository Pattern only when you need fine-grained control over individual managers

Next: Defining Models