Facade Pattern — Фасад

Простой интерфейс к сложной подсистеме. Главный паттерн архитектурных границ по автору — «полюбите его больше остальных».

Проблема

Подсистема состоит из десятков/сотен классов. Клиент не должен знать про каждый. Нужно:

  • упростить API
  • защитить внутренности от прямого доступа
  • сделать архитектурные границы между слоями

Сложные подсистемы (IndexedDB, WebAudio API, сетевой слой с retry/кэшированием/авторизацией) требуют десятков вызовов для одной логической операции. Facade превращает это в db.getUser(id).

Где используется

  • HTTP-клиент: Facade над fetch с авторизацией, retry, error handling
  • IndexedDB: упрощение сложного асинхронного API
  • Аудио/видео: Facade над Web Audio API или MediaStream API
  • Уведомления: единый API для Web Push, email, SMS
  • Библиотеки и SDK: axios, firebase/app — готовые Facade

Решение

Один класс/функция/модуль с простым публичным API. Внутри:

  • собирает много классов в логически связанную подсистему
  • делегирует операции внутрь
  • скрывает сложность

Реализации

TimeoutCollection (короткий пример)

class Collection { /* ... */ }
class TimerManager { /* ... */ }
class ExpirationPolicy { /* ... */ }

class TimeoutCollection {
  #collection = new Collection();
  #timers = new TimerManager();
  #policy;

  constructor(timeout) { this.#policy = new ExpirationPolicy(timeout); }

  set(key, value) {
    this.#collection.set(key, value);
    this.#timers.schedule(key, () => this.#collection.delete(key), this.#policy.timeout);
  }
  get(key) { return this.#collection.get(key); }
  delete(key) {
    this.#timers.cancel(key);
    this.#collection.delete(key);
  }
  toArray { return this.#collection.toArray; }
}

const cache = new TimeoutCollection(1000);
cache.set('key', 'value');

HTTP-клиент

class HttpFacade {
  constructor(baseUrl, options = {}) {
    this.baseUrl = baseUrl;
    this.retries = options.retries ?? 3;
    this.timeout = options.timeout ?? 10000;
  }

  async #fetchWithRetry(url, options, retries) {
    const controller = new AbortController();
    const timeoutId = setTimeout( => controller.abort(), this.timeout);
    try {
      const res = await fetch(url, { ...options, signal: controller.signal });
      clearTimeout(timeoutId);
      if (!res.ok) {
        if (res.status >= 500 && retries > 0) {
          await new Promise(r => setTimeout(r, 1000));
          return this.#fetchWithRetry(url, options, retries - 1);
        }
        throw new Error(`HTTP ${res.status}`);
      }
      return res.json();
    } catch (err) {
      clearTimeout(timeoutId);
      throw err;
    }
  }

  #getHeaders {
    const token = localStorage.getItem('token');
    return {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    };
  }

  get(path) {
    return this.#fetchWithRetry(`${this.baseUrl}${path}`, { method: 'GET', headers: this.#getHeaders }, this.retries);
  }
  post(path, body) {
    return this.#fetchWithRetry(`${this.baseUrl}${path}`, { method: 'POST', headers: this.#getHeaders, body: JSON.stringify(body) }, this.retries);
  }
}

const api = new HttpFacade('https://api.example.com');
const user = await api.get('/users/1');

Facade для IndexedDB

class IndexedDBFacade {
  constructor(dbName, version = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

  async open(stores = ) {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(this.dbName, this.version);
      req.onupgradeneeded = (e) => {
        const db = e.target.result;
        stores.forEach(s => { if (!db.objectStoreNames.contains(s)) db.createObjectStore(s, { keyPath: 'id' }); });
      };
      req.onsuccess = (e) => { this.db = e.target.result; resolve(this); };
      req.onerror = (e) => reject(e.target.error);
    });
  }

  async get(store, id) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(store, 'readonly');
      const req = tx.objectStore(store).get(id);
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  async put(store, item) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(store, 'readwrite');
      const req = tx.objectStore(store).put(item);
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }
}

Где используется в JS-экосистеме

  • jQuery — фасад над DOM, AJAX, animations
  • Lodash/Underscore — фасад над работой с коллекциями
  • Axios — фасад над XMLHttpRequest/fetch
  • Express App — фасад над HTTP-server
  • API Gateway — фасад на уровне микросервисов

Подводные камни

  • Facade vs Adapter: Adapter — одна абстракция за другой; Facade — много за одной.
  • Facade vs Composite: Composite — дерево однотипных узлов; Facade — несвязанные классы за одним API.
  • God object antipattern: если фасад делает всё подряд — это не фасад, а помойка.
  • Фасад должен скрывать логически связанную подсистему — не группировать просто по факту существования.
  • Facade скрывает полезный API: если клиентам нужен доступ к деталям, предоставьте через свойство.

Главные тезисы автора

  • «Этот паттерн вы все должны полюбить больше остальных».
  • «Декомпозиция кода на модули и отделение их друг от друга контрактами».
  • Архитектурные границы: «все слои друг от друга должны быть отделены фасадами».
  • «Нужен банан — получаешь обезьяну с пальмой и тропический лес» — фасад отдаёт только нужное.
  • API Gateway = Facade на уровне микросервисов — тот же принцип, другой уровень.
  • Фасад в JS может быть: функцией, фабрикой, классом, прототипом, модулем — что угодно.
  • «Самое главное не в том, что взяли пачку классов и скрыли. Они должны быть связаны логически».

🎓 Источники

См. также