Repository pattern поверх Dexie

Repository pattern в контексте работы с IndexedDB и Dexie.js используется как слой абстракции между бизнес-логикой приложения и низкоуровневым API хранения данных. Основная цель такого подхода — изолировать работу с базой данных, упростить тестирование, централизовать запросы и обеспечить единообразный доступ к данным независимо от структуры хранилища.

Dexie предоставляет удобный API поверх IndexedDB, но при прямом использовании в бизнес-логике приложения возникает сильная связность компонентов с конкретной реализацией хранилища. Repository pattern решает эту проблему, вводя промежуточный слой, который скрывает детали Dexie и предоставляет доменно-ориентированные методы.


Базовая идея репозитория

Repository — это класс или модуль, который:

  • инкапсулирует операции доступа к данным;
  • предоставляет методы, отражающие предметную область;
  • скрывает детали реализации (Dexie, IndexedDB, запросы, индексы);
  • возвращает чистые доменные объекты или DTO.

Вместо:

db.users.where('age').above(18).toArray()

используется:

userRepository.getAdults()

Такое разделение делает код более устойчивым к изменениям структуры базы данных.


Почему Dexie нуждается в репозиториях

Dexie, несмотря на высокоуровневый API, всё равно остаётся ORM-подобным слоем доступа к IndexedDB. Это означает:

  • запросы «протекают» в бизнес-логику;
  • сложные цепочки .where().filter().sortBy() засоряют сервисы;
  • тестирование требует мокать Dexie напрямую;
  • изменение схемы базы приводит к правкам во многих местах.

Repository pattern решает это за счёт централизованного API.


Базовая структура слоя доступа

Типичная архитектура с репозиторием поверх Dexie выглядит так:

UI / Controllers
      ↓
Service Layer
      ↓
Repository Layer
      ↓
Dexie (IndexedDB)

Repository становится единственной точкой взаимодействия с базой данных.


Простейшая реализация репозитория

class UserRepository {
  constructor(db) {
    this.db = db;
  }

  async getById(id) {
    return this.db.users.get(id);
  }

  async getAll() {
    return this.db.users.toArray();
  }

  async add(user) {
    return this.db.users.add(user);
  }

  async upd ate(id, changes) {
    return this.db.users.update(id, changes);
  }

  async delete(id) {
    return this.db.users.delete(id);
  }
}

Этот уровень уже устраняет прямую зависимость бизнес-логики от Dexie.


Доменные методы вместо сырого API

Суть repository pattern проявляется в добавлении методов, отражающих бизнес-логику:

class UserRepository {
  constructor(db) {
    this.db = db;
  }

  async getActiveUsers() {
    return this.db.users.where('isActive').equals(1).toArray();
  }

  async getAdults() {
    return this.db.users.where('age').aboveOrEqual(18).toArray();
  }

  async searchByEmail(email) {
    return this.db.users.where('email').equalsIgnoreCase(email).first();
  }
}

Теперь изменения в структуре таблицы или индексов не требуют правок в сервисах приложения.


Инкапсуляция сложных запросов

Dexie позволяет строить сложные цепочки запросов, но они плохо читаются при дублировании:

const result = await db.orders
  .where('status')
  .equals('paid')
  .and(order => order.total > 100)
  .toArray();

В репозитории это превращается в доменный метод:

class OrderRepository {
  constructor(db) {
    this.db = db;
  }

  async getPremiumPaidOrders(minTotal) {
    return this.db.orders
      .where('status')
      .equals('paid')
      .and(order => order.total > minTotal)
      .toArray();
  }
}

На уровне сервиса:

const orders = await orderRepository.getPremiumPaidOrders(100);

Разделение DTO и доменных моделей

Repository может выполнять трансформацию данных:

class UserRepository {
  constructor(db) {
    this.db = db;
  }

  mapToDomain(userRow) {
    return {
      id: userRow.id,
      fullName: `${userRow.firstName} ${userRow.lastName}`,
      age: userRow.age,
      isActive: Boolean(userRow.isActive)
    };
  }

  async getById(id) {
    const user = await this.db.users.get(id);
    return user ? this.mapToDomain(user) : null;
  }
}

Это снижает зависимость бизнес-логики от структуры таблиц Dexie.


Repository как точка управления транзакциями

Dexie поддерживает транзакции, и repository может централизовать их использование:

class OrderRepository {
  constructor(db) {
    this.db = db;
  }

  async createOrderWithItems(order, items) {
    return this.db.transaction('rw', this.db.orders, this.db.items, async () => {
      const orderId = await this.db.orders.add(order);

      for (const item of items) {
        await this.db.items.add({
          ...item,
          orderId
        });
      }

      return orderId;
    });
  }
}

Сервису не нужно знать о транзакционной модели Dexie.


Композиция репозиториев

В крупных приложениях репозитории могут зависеть друг от друга:

class UserRepository {
  constructor(db) {
    this.db = db;
  }

  async getById(id) {
    return this.db.users.get(id);
  }
}

class OrderRepository {
  constructor(db, userRepository) {
    this.db = db;
    this.userRepository = userRepository;
  }

  async getOrdersWithUsers() {
    const orders = await this.db.orders.toArray();

    const result = await Promise.all(
      orders.map(async order => {
        const user = await this.userRepository.getById(order.userId);
        return { ...order, user };
      })
    );

    return result;
  }
}

Такой подход усиливает модульность, но требует контроля над количеством запросов.


Оптимизация запросов внутри репозитория

Repository слой позволяет централизованно внедрять оптимизации:

  • использование индексов Dexie;
  • кеширование результатов;
  • батчинг запросов;
  • минимизация round-trip к IndexedDB.

Пример кеширования:

class UserRepository {
  constructor(db) {
    this.db = db;
    this.cache = new Map();
  }

  async getById(id) {
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }

    const user = await this.db.users.get(id);
    this.cache.se t(id, user);

    return user;
  }
}

Тестирование репозиториев

Repository pattern упрощает тестирование, поскольку Dexie можно заменить мок-объектом:

const mockDb = {
  users: {
    get: jest.fn(),
    toArray: jest.fn()
  }
};

const repo = new UserRepository(mockDb);

test('getById returns user', async () => {
  mockDb.users.get.mockResolvedValue({ id: 1, name: 'A' });

  const result = await repo.getById(1);

  expect(result).toEqual({ id: 1, name: 'A' });
});

Бизнес-логика не зависит от IndexedDB и Dexie напрямую.


Антипаттерны при использовании repository поверх Dexie

Протекание Dexie наружу

// плохо
class UserRepository {
  getUsers() {
    return this.db.users.where('age').above(18);
  }
}

Здесь возвращается Dexie Query, что ломает абстракцию.


Слишком толстый repository

Repository не должен превращаться в сервисный слой с бизнес-логикой:

  • расчёты;
  • сложные правила;
  • оркестрация нескольких доменных процессов.

Дублирование логики запросов

Если одинаковые .where() повторяются в разных местах, они должны быть вынесены в методы repository.


Repository и масштабирование приложения

При росте приложения repository слой выполняет роль стабилизирующего элемента архитектуры:

  • снижает связность UI и хранения;
  • упрощает миграцию IndexedDB схем;
  • позволяет заменить Dexie на другой слой хранения без переписывания бизнес-логики;
  • создаёт единый контракт доступа к данным.

Dexie при этом остаётся инфраструктурной деталью, скрытой за репозиториями, а вся предметная логика выражается через методы уровня домена.