September 15, 2024

I in SOLID

Принцип разделения интерфейсов

Interface segregation — клиенты не должны зависеть от интерфейсов, которые они не используют.

Данный принцип предостерегает от создания «толстых» интерфейсов, чтобы избежать ситуаций, когда какой-нибудь класс вынужден реализовывать поведение, которое ему не нужно.

https://github.com/jafari-dev/oop-expert-with-typescript

Паттерн "репозиторий"

Предположим, мы хотим воспользоваться паттерном «репозиторий» для реализации crud-операций нашего REST API:

interface Repository<T> {
  create(item: T): void;
  read(): T[];
  update(item: T): void;
  delete(item: T): void;
}

abstract class AbstractRepository<T> implements Repository<T> {
  abstract create(item: T): void;
  abstract read(): T[];
  abstract update(item: T): void;
  abstract delete(item: T): void;
}


interface Item {
  name: string;
}

class SimpleItemsRepository extends AbstractRepository<Item> {
  private items: Item[] = [];

  create(item: Item): void {
    this.items.push(item);
  }

  read(): Item[] {
    return this.items;
  }

  update(updatedItem: Item): void {
    const index = this.items.findIndex(item => item === updatedItem);
    if (index !== -1) {
      this.items[index] = updatedItem;
    } else {
      console.error("Item not found");
    }
  }

  delete(item: Item): void {
    this.items = this.items.filter(i => i !== item);
  }
}

Казалось бы, всё в порядке. Теперь с помощью реализованного репозитория мы можем работать с разными сущностями и хранилищами. Но вдруг у нас появится сущность, для которой не все CRUD-операции разрешены? Например, мы можем только получать данные, но не можем ничего обновлять и удалять.

class OnlyReadSimpleItemsRepository extends AbstractRepository<Item> {
  private items: Item[] = [];

  create(item: Item): void {
    console.error("Not allowed");
  }

  read(): Item[] {
    return this.items;
  }

  update(updatedItem: Item): void {
    console.error("Not allowed");
  }

  delete(item: Item): void {
    console.error("Not allowed");
  }
}

Получается ситуация, когда OnlyReadItemsRepository реализует методы, которые ему фактически не нужны. Это плохо, потому что возникает несоответствие ожиданиям реализации интерфейса — вместо желаемого удаления при вызове метода ничего не происходит, и в лучшем случае мы заметим это только в рантайме.

Вместо этого необходимо разделить этот интерфейс. В рамках данного примера реализация каждой операции отдельным интерфейсом кажется избыточной, поэтому разделим операции на «чтение» и «запись».

interface RepositoryRead<T> {
  read(): T[];
}

interface RepositoryWrite<T> {
  create(item: T): void;
  update(item: T): void; 
  delete(item: T): void;
}

abstract class AbstractRepository<T> implements RepositoryRead<T>, RepositoryWrite<T> {
  abstract create(item: T): void;
  abstract read(): T[];
  abstract update(item: T): void;
  abstract delete(item: T): void;
}


interface Item {
  name: string;
}

class OnlyReadSimpleItemsRepository implements RepositoryRead<Item>
  private items: Item[] = [];

  read(): Item[] {
    return this.items;
  }
}

class SimpleItemsRepository extends AbstractRepository<Item> {
  //...
}

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

Хуки жизненного цикла

Ярким примером принципа разделения интерфейсов в Angular может послужить реализация хуков жизненного цикла компонентов. Напомню, каждый из 8 хуков описывается независимым интерфейсом. Мы лишь имплементируем для каждого компонента нужные хуки.

import { Component, OnInit, AfterViewInit, OnDestroy} from '@angular/core';

@Component({
  //...
})
export class SomeComponent implements OnInit, AfterViewInit, OnDestroy {
  constructor() {
    //...
  }

  ngOnInit(): void {
    //...
  }

  ngAfterViewInit(): void {
    //...
  }

  ngOnDestroy(): void {
    //...
  }
}

А что бы было, если бы все хуки были бы объединены в один интерфейс, например, ComponentLifecycle? Тогда наши компоненты должны были бы реализовывать все хуки, независимо от того, какие из них действительно требуются.

import { SimpleChanges } from '@angular/core';

export interface ComponentLifecycle {
  ngOnChanges(changes: SimpleChanges): void;
  ngOnInit(): void;
  ngDoCheck(): void;
  ngAfterContentInit(): void;
  ngAfterContentChecked(): void;
  ngAfterViewInit(): void;
  ngAfterViewChecked(): void;
  ngOnDestroy(): void;
}

При этом эти хуки постоянно вызывались бы, так как определить какой из них является заглушкой, а какой нет, Angular не сможет. Также если вдруг когда-нибудь возникнет новый хук жизненного цикла, мы будем вынуждены обновить наш «толстый» интерфейс, и старые компоненты сломаются, так как не будут реализовывать новый метод, а значит не будут соответствовать интерфейсу.

Итого

Следование принципу разделения интерфейсов обеспечивает более гибкую и прозрачную работу с интерфейсами, позволяет исключить бесполезный код, а также упрощает поддержку и масштабирование с точки зрения обратной совместимости 👍.