Skip to the content.

Common Patterns

This guide covers frequently used patterns and solutions when working with Firebase ORM.

CRUD Operations

Standard CRUD Pattern

import { Field, BaseModel, Model } from "@arbel/firebase-orm";

@Model({
  reference_path: "products",
  path_id: "product_id"
})
export class Product extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  @Field({ is_required: true })
  public price!: number;

  @Field({ is_required: false })
  public description?: string;

  @Field({ field_name: "created_at" })
  public createdAt?: string;

  @Field({ field_name: "updated_at" })
  public updatedAt?: string;
}

// CRUD Service
export class ProductService {
  // Create
  async createProduct(data: Partial<Product>): Promise<Product> {
    const product = new Product();
    Object.assign(product, data);
    product.createdAt = new Date().toISOString();
    product.updatedAt = new Date().toISOString();
    
    return await product.save();
  }

  // Read
  async getProduct(id: string): Promise<Product> {
    const product = new Product();
    await product.load(id);
    return product;
  }

  async getAllProducts(): Promise<Product[]> {
    return await Product.getAll();
  }

  async getProductsByCategory(category: string): Promise<Product[]> {
    return await Product.query()
      .where('category', '==', category)
      .orderBy('createdAt', 'desc')
      .get();
  }

  // Update
  async updateProduct(id: string, updates: Partial<Product>): Promise<Product> {
    const product = await this.getProduct(id);
    Object.assign(product, updates);
    product.updatedAt = new Date().toISOString();
    
    return await product.save();
  }

  // Delete
  async deleteProduct(id: string): Promise<void> {
    const product = await this.getProduct(id);
    await product.destroy();
  }

  // Soft Delete
  async softDeleteProduct(id: string): Promise<Product> {
    return await this.updateProduct(id, { 
      deletedAt: new Date().toISOString() 
    });
  }
}

Repository Pattern

Generic Repository

export interface IRepository<T extends BaseModel> {
  findById(id: string): Promise<T>;
  findAll(): Promise<T[]>;
  findBy(field: string, value: any): Promise<T[]>;
  create(data: Partial<T>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

export class BaseRepository<T extends BaseModel> implements IRepository<T> {
  constructor(private ModelClass: new () => T) {}

  async findById(id: string): Promise<T> {
    const model = new this.ModelClass();
    await model.load(id);
    return model;
  }

  async findAll(): Promise<T[]> {
    return await (this.ModelClass as any).getAll();
  }

  async findBy(field: string, value: any): Promise<T[]> {
    return await (this.ModelClass as any).query()
      .where(field, '==', value)
      .get();
  }

  async create(data: Partial<T>): Promise<T> {
    const model = new this.ModelClass();
    Object.assign(model, data);
    return await model.save();
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    const model = await this.findById(id);
    Object.assign(model, data);
    return await model.save();
  }

  async delete(id: string): Promise<void> {
    const model = await this.findById(id);
    await model.destroy();
  }
}

// Specific repository
export class ProductRepository extends BaseRepository<Product> {
  constructor() {
    super(Product);
  }

  async findByPriceRange(min: number, max: number): Promise<Product[]> {
    return await Product.query()
      .where('price', '>=', min)
      .where('price', '<=', max)
      .get();
  }

  async findInStock(): Promise<Product[]> {
    return await Product.query()
      .where('quantity', '>', 0)
      .get();
  }

  async findFeatured(): Promise<Product[]> {
    return await Product.query()
      .where('isFeatured', '==', true)
      .orderBy('createdAt', 'desc')
      .limit(10)
      .get();
  }
}

Pagination Patterns

Cursor-Based Pagination

export interface PaginationResult<T> {
  items: T[];
  hasNext: boolean;
  nextCursor?: string;
  total?: number;
}

export class PaginationService {
  static async paginate<T extends BaseModel>(
    ModelClass: new () => T,
    options: {
      limit?: number;
      cursor?: string;
      orderBy?: string;
      orderDirection?: 'asc' | 'desc';
      where?: Array<{ field: string; operator: string; value: any }>;
    }
  ): Promise<PaginationResult<T>> {
    const limit = options.limit || 20;
    const orderBy = options.orderBy || 'createdAt';
    const orderDirection = options.orderDirection || 'desc';

    let query = (ModelClass as any).query()
      .orderBy(orderBy, orderDirection)
      .limit(limit + 1); // Get one extra to check if there's more

    // Apply where conditions
    if (options.where) {
      options.where.forEach(condition => {
        query = query.where(condition.field, condition.operator, condition.value);
      });
    }

    // Apply cursor
    if (options.cursor) {
      if (orderDirection === 'desc') {
        query = query.startAfter(options.cursor);
      } else {
        query = query.startAfter(options.cursor);
      }
    }

    const results = await query.get();
    const hasNext = results.length > limit;
    
    if (hasNext) {
      results.pop(); // Remove the extra item
    }

    const nextCursor = hasNext && results.length > 0 
      ? results[results.length - 1][orderBy]
      : undefined;

    return {
      items: results,
      hasNext,
      nextCursor
    };
  }
}

// Usage
const paginateProducts = async (cursor?: string) => {
  return await PaginationService.paginate(Product, {
    limit: 10,
    cursor,
    orderBy: 'createdAt',
    orderDirection: 'desc',
    where: [{ field: 'isActive', operator: '==', value: true }]
  });
};

Offset-Based Pagination

export interface OffsetPaginationResult<T> {
  items: T[];
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasNext: boolean;
  hasPrevious: boolean;
}

export class OffsetPaginationService {
  static async paginate<T extends BaseModel>(
    ModelClass: new () => T,
    page: number = 1,
    limit: number = 20,
    options?: {
      orderBy?: string;
      orderDirection?: 'asc' | 'desc';
      where?: Array<{ field: string; operator: string; value: any }>;
    }
  ): Promise<OffsetPaginationResult<T>> {
    const offset = (page - 1) * limit;
    const orderBy = options?.orderBy || 'createdAt';
    const orderDirection = options?.orderDirection || 'desc';

    let query = (ModelClass as any).query()
      .orderBy(orderBy, orderDirection);

    // Apply where conditions
    if (options?.where) {
      options.where.forEach(condition => {
        query = query.where(condition.field, condition.operator, condition.value);
      });
    }

    // Get total count (for offset pagination, we need total count)
    const allResults = await query.get();
    const total = allResults.length;

    // Apply pagination
    const items = allResults.slice(offset, offset + limit);

    const totalPages = Math.ceil(total / limit);

    return {
      items,
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrevious: page > 1
    };
  }
}

Search Patterns

Full-Text Search with Indexing

@Model({
  reference_path: "articles",
  path_id: "article_id"
})
export class Article extends BaseModel {
  @Field({ 
    is_required: true,
    is_text_indexing: true 
  })
  public title!: string;

  @Field({ 
    is_required: true,
    is_text_indexing: true 
  })
  public content!: string;

  @Field({ is_required: false })
  public tags?: string[];

  @Field({ field_name: "search_keywords" })
  public searchKeywords?: string[];

  // Generate search keywords when saving
  async beforeSave(): Promise<void> {
    this.generateSearchKeywords();
  }

  private generateSearchKeywords(): void {
    const keywords = new Set<string>();
    
    // Add title words
    this.title.toLowerCase().split(/\s+/).forEach(word => {
      if (word.length >= 3) {
        keywords.add(word);
      }
    });

    // Add content words (first 100 words)
    const contentWords = this.content.toLowerCase()
      .split(/\s+/)
      .slice(0, 100);
    
    contentWords.forEach(word => {
      if (word.length >= 3) {
        keywords.add(word);
      }
    });

    // Add tags
    this.tags?.forEach(tag => {
      keywords.add(tag.toLowerCase());
    });

    this.searchKeywords = Array.from(keywords);
  }
}

export class SearchService {
  async searchArticles(query: string): Promise<Article[]> {
    const searchTerms = query.toLowerCase().split(/\s+/)
      .filter(term => term.length >= 3);

    if (searchTerms.length === 0) {
      return [];
    }

    // Search using LIKE operator
    const results = await Article.query()
      .like('title', `%${query}%`)
      .get();

    // Also search in keywords
    const keywordResults = await Article.query()
      .where('searchKeywords', 'array-contains-any', searchTerms)
      .get();

    // Combine and deduplicate results
    const allResults = [...results, ...keywordResults];
    const uniqueResults = allResults.filter((article, index, array) => 
      array.findIndex(a => a.getId() === article.getId()) === index
    );

    return this.rankSearchResults(uniqueResults, query);
  }

  private rankSearchResults(articles: Article[], query: string): Article[] {
    const queryLower = query.toLowerCase();
    
    return articles
      .map(article => ({
        article,
        score: this.calculateRelevanceScore(article, queryLower)
      }))
      .sort((a, b) => b.score - a.score)
      .map(result => result.article);
  }

  private calculateRelevanceScore(article: Article, query: string): number {
    let score = 0;
    
    // Title matches are worth more
    if (article.title.toLowerCase().includes(query)) {
      score += 10;
    }
    
    // Content matches
    if (article.content.toLowerCase().includes(query)) {
      score += 5;
    }
    
    // Tag matches
    article.tags?.forEach(tag => {
      if (tag.toLowerCase().includes(query)) {
        score += 7;
      }
    });
    
    return score;
  }
}

Caching Patterns

Model-Level Caching

export class CacheService {
  private static cache = new Map<string, { data: any; timestamp: number; ttl: number }>();

  static set(key: string, data: any, ttlMs: number = 300000): void {
    this.cache.set(key, {
      data: JSON.parse(JSON.stringify(data)), // Deep clone
      timestamp: Date.now(),
      ttl: ttlMs
    });
  }

  static get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    if (Date.now() - entry.timestamp > entry.ttl) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

  static invalidate(pattern: string): void {
    const regex = new RegExp(pattern);
    for (const key of this.cache.keys()) {
      if (regex.test(key)) {
        this.cache.delete(key);
      }
    }
  }

  static clear(): void {
    this.cache.clear();
  }
}

// Cached model mixin
export class CachedBaseModel extends BaseModel {
  async load(id: string): Promise<this> {
    const cacheKey = `${this.constructor.name}_${id}`;
    const cached = CacheService.get<any>(cacheKey);
    
    if (cached) {
      Object.assign(this, cached);
      return this;
    }

    await super.load(id);
    CacheService.set(cacheKey, this.toJSON(), 300000); // Cache for 5 minutes
    return this;
  }

  async save(): Promise<this> {
    const result = await super.save();
    
    // Invalidate cache
    const cacheKey = `${this.constructor.name}_${this.getId()}`;
    CacheService.invalidate(cacheKey);
    
    return result;
  }

  async destroy(): Promise<void> {
    await super.destroy();
    
    // Invalidate cache
    const cacheKey = `${this.constructor.name}_${this.getId()}`;
    CacheService.invalidate(cacheKey);
  }
}

Query Result Caching

export class QueryCacheService {
  private static cache = new Map<string, any>();

  static async getCachedQuery<T>(
    cacheKey: string,
    queryFn: () => Promise<T>,
    ttlMs: number = 300000
  ): Promise<T> {
    const cached = this.cache.get(cacheKey);
    
    if (cached && Date.now() - cached.timestamp < ttlMs) {
      return cached.data;
    }

    const result = await queryFn();
    this.cache.set(cacheKey, {
      data: result,
      timestamp: Date.now()
    });

    return result;
  }

  static invalidatePattern(pattern: string): void {
    const regex = new RegExp(pattern);
    for (const key of this.cache.keys()) {
      if (regex.test(key)) {
        this.cache.delete(key);
      }
    }
  }
}

// Usage
export class ProductService {
  async getProductsByCategory(category: string): Promise<Product[]> {
    const cacheKey = `products_category_${category}`;
    
    return QueryCacheService.getCachedQuery(
      cacheKey,
      () => Product.query()
        .where('category', '==', category)
        .where('isActive', '==', true)
        .get(),
      300000 // 5 minutes
    );
  }

  async invalidateProductCache(category?: string): Promise<void> {
    if (category) {
      QueryCacheService.invalidatePattern(`products_category_${category}`);
    } else {
      QueryCacheService.invalidatePattern('products_.*');
    }
  }
}

Event-Driven Patterns

Observer Pattern

export interface ModelEventListener<T extends BaseModel> {
  beforeSave?(model: T): Promise<void> | void;
  afterSave?(model: T): Promise<void> | void;
  beforeDestroy?(model: T): Promise<void> | void;
  afterDestroy?(model: T): Promise<void> | void;
}

export class EventManager {
  private static listeners = new Map<string, ModelEventListener<any>[]>();

  static addListener<T extends BaseModel>(
    modelName: string, 
    listener: ModelEventListener<T>
  ): void {
    if (!this.listeners.has(modelName)) {
      this.listeners.set(modelName, []);
    }
    this.listeners.get(modelName)!.push(listener);
  }

  static async emit<T extends BaseModel>(
    modelName: string,
    event: keyof ModelEventListener<T>,
    model: T
  ): Promise<void> {
    const listeners = this.listeners.get(modelName) || [];
    
    for (const listener of listeners) {
      const handler = listener[event];
      if (handler) {
        await handler.call(listener, model);
      }
    }
  }
}

// Enhanced base model with events
export class EventfulBaseModel extends BaseModel {
  async save(): Promise<this> {
    await EventManager.emit(this.constructor.name, 'beforeSave', this);
    const result = await super.save();
    await EventManager.emit(this.constructor.name, 'afterSave', this);
    return result;
  }

  async destroy(): Promise<void> {
    await EventManager.emit(this.constructor.name, 'beforeDestroy', this);
    await super.destroy();
    await EventManager.emit(this.constructor.name, 'afterDestroy', this);
  }
}

// Example listeners
class ProductEventListener implements ModelEventListener<Product> {
  async afterSave(product: Product): Promise<void> {
    // Invalidate cache
    await ProductService.invalidateProductCache(product.category);
    
    // Send notification
    await NotificationService.send({
      type: 'product_updated',
      productId: product.getId(),
      productName: product.name
    });
  }

  async afterDestroy(product: Product): Promise<void> {
    // Clean up related data
    await ImageService.deleteProductImages(product.getId());
  }
}

// Register listeners
EventManager.addListener('Product', new ProductEventListener());

Validation Patterns

Decorator-Based Validation

// Validation decorators
export function Required(message?: string) {
  return function (target: any, propertyKey: string) {
    const validations = Reflect.getMetadata('validations', target) || [];
    validations.push({
      property: propertyKey,
      validator: (value: any) => value != null && value !== '',
      message: message || `${propertyKey} is required`
    });
    Reflect.defineMetadata('validations', validations, target);
  };
}

export function MinLength(min: number, message?: string) {
  return function (target: any, propertyKey: string) {
    const validations = Reflect.getMetadata('validations', target) || [];
    validations.push({
      property: propertyKey,
      validator: (value: string) => !value || value.length >= min,
      message: message || `${propertyKey} must be at least ${min} characters`
    });
    Reflect.defineMetadata('validations', validations, target);
  };
}

export function Email(message?: string) {
  return function (target: any, propertyKey: string) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const validations = Reflect.getMetadata('validations', target) || [];
    validations.push({
      property: propertyKey,
      validator: (value: string) => !value || emailRegex.test(value),
      message: message || `${propertyKey} must be a valid email`
    });
    Reflect.defineMetadata('validations', validations, target);
  };
}

// Validatable base model
export class ValidatableBaseModel extends BaseModel {
  async validate(): Promise<void> {
    const validations = Reflect.getMetadata('validations', this) || [];
    const errors: string[] = [];

    for (const validation of validations) {
      const value = (this as any)[validation.property];
      if (!validation.validator(value)) {
        errors.push(validation.message);
      }
    }

    if (errors.length > 0) {
      throw new Error(`Validation failed: ${errors.join(', ')}`);
    }
  }

  async save(): Promise<this> {
    await this.validate();
    return super.save();
  }
}

// Usage
@Model({
  reference_path: "users",
  path_id: "user_id"
})
export class User extends ValidatableBaseModel {
  @Field({ is_required: true })
  @Required('Name is required')
  @MinLength(2, 'Name must be at least 2 characters')
  public name!: string;

  @Field({ is_required: true })
  @Required('Email is required')
  @Email('Please provide a valid email')
  public email!: string;
}

Factory Pattern

Model Factory

export class ModelFactory {
  static create<T extends BaseModel>(
    ModelClass: new () => T,
    data: Partial<T>
  ): T {
    const model = new ModelClass();
    Object.assign(model, data);
    return model;
  }

  static async createAndSave<T extends BaseModel>(
    ModelClass: new () => T,
    data: Partial<T>
  ): Promise<T> {
    const model = this.create(ModelClass, data);
    return await model.save();
  }

  static createMultiple<T extends BaseModel>(
    ModelClass: new () => T,
    dataArray: Array<Partial<T>>
  ): T[] {
    return dataArray.map(data => this.create(ModelClass, data));
  }

  static async createMultipleAndSave<T extends BaseModel>(
    ModelClass: new () => T,
    dataArray: Array<Partial<T>>
  ): Promise<T[]> {
    const models = this.createMultiple(ModelClass, dataArray);
    return await Promise.all(models.map(model => model.save()));
  }
}

// Test data factory
export class TestDataFactory {
  static createUser(overrides?: Partial<User>): User {
    return ModelFactory.create(User, {
      name: 'Test User',
      email: 'test@example.com',
      role: 'user',
      ...overrides
    });
  }

  static createProduct(overrides?: Partial<Product>): Product {
    return ModelFactory.create(Product, {
      name: 'Test Product',
      price: 99.99,
      category: 'electronics',
      ...overrides
    });
  }

  static async seedDatabase(): Promise<void> {
    // Create test users
    const users = await ModelFactory.createMultipleAndSave(User, [
      { name: 'John Doe', email: 'john@example.com' },
      { name: 'Jane Smith', email: 'jane@example.com' },
      { name: 'Admin User', email: 'admin@example.com', role: 'admin' }
    ]);

    // Create test products
    await ModelFactory.createMultipleAndSave(Product, [
      { name: 'Laptop', price: 999.99, category: 'electronics' },
      { name: 'Book', price: 19.99, category: 'books' },
      { name: 'Coffee Mug', price: 9.99, category: 'home' }
    ]);
  }
}

Error Handling Patterns

Result Pattern

export class Result<T, E = Error> {
  private constructor(
    private readonly success: boolean,
    private readonly data?: T,
    private readonly error?: E
  ) {}

  static ok<T>(data: T): Result<T> {
    return new Result(true, data);
  }

  static error<E>(error: E): Result<never, E> {
    return new Result(false, undefined, error);
  }

  isOk(): boolean {
    return this.success;
  }

  isError(): boolean {
    return !this.success;
  }

  getData(): T {
    if (!this.success) {
      throw new Error('Cannot get data from error result');
    }
    return this.data!;
  }

  getError(): E {
    if (this.success) {
      throw new Error('Cannot get error from success result');
    }
    return this.error!;
  }

  map<U>(fn: (data: T) => U): Result<U, E> {
    if (this.success) {
      return Result.ok(fn(this.data!));
    }
    return Result.error(this.error!);
  }

  mapError<F>(fn: (error: E) => F): Result<T, F> {
    if (!this.success) {
      return Result.error(fn(this.error!));
    }
    return Result.ok(this.data!);
  }
}

// Service with Result pattern
export class SafeProductService {
  async getProduct(id: string): Promise<Result<Product, string>> {
    try {
      const product = new Product();
      await product.load(id);
      return Result.ok(product);
    } catch (error) {
      return Result.error('Product not found');
    }
  }

  async createProduct(data: Partial<Product>): Promise<Result<Product, string>> {
    try {
      const product = ModelFactory.create(Product, data);
      await product.save();
      return Result.ok(product);
    } catch (error) {
      return Result.error(`Failed to create product: ${error.message}`);
    }
  }
}

// Usage
const productService = new SafeProductService();

const result = await productService.getProduct('invalid-id');
if (result.isOk()) {
  const product = result.getData();
  console.log(product.name);
} else {
  console.error(result.getError());
}