SOLID

Spis treści

Co to jest ?

To zbiór pięciu zasad, które definiują, w jaki sposób powinien być budowany, rozwijany i utrzymywany projekt od strony kodu. Korzystając z tych zasad kod,
który powstanie nie powinien przypominać plątaniny przewodów podłączonych do bomby, która wybuchnie jeżeli usuniemy niewłaściwy przewód.

Przykładowa aplikacja

Aby w przystępny sposób zaprezentować dlaczego warto stosować zasady SOLID stworzyłem prostą aplikację, w której zaprezentowałem różnice między kodem, w który nie uwzględniono tych zasad a kodem korzystającym z nich w prawidłowy sposób.
Przyjmijmy, że tworzymy system przeznaczony dla pracowników uczelni wyższej, który ma być odpowiedzialny za tworzenie raportów na temat wyników studentów oraz komunikacji z nimi.

S: Single Responsibility Principle(SRP)

Klasy i metody powinny być odpowiedzialne tylko za jedną rzecz oraz powinna istnieć tylko jeden powód aby je zmieniać

SRP wymaga, aby klasa miała tylko jeden powód do zmiany. Klasa zgodna z tą zasadą wykonuje tylko kilka powiązanych zadań. Myśląc w ramach SRP, nie trzeba ograniczać się tylko do problemów powiązanych z klasami.
Zasadę SRP można zastosować także do metod czy modułów, upewniając się, że są odpowiedzialne, tylko za jedną rzecz oraz że mają tylko jeden powód do zmiany.

Historyjka:

Doktor Jan Kowalski zgłosił potrzebę generowania raportów z wynikami jego studentów z różnych grup, oraz możliwości przesyłania tych raportów.

Złe podejście

class StudentsReport {
    private report: Map<number, Student> = new Map<number, Student>();

    constructor(students: StudentData[]) {
        this.createReport(students);
    }

    private createReport(students: StudentData[]): void {
        // create report logic  
    }

    private formatReport(type: ReportType): HTMLElement {
        const formattedReport = document.createElement('table');
        switch (type) {
            case ReportType.DEAN:
                // Logic responsible for generating report for dean  
                break;
            case ReportType.LECTURER:
                // Logic responsible for generating report for lecturer  
                break;
            case ReportType.UNIVERSITY_WORKER:
                // Logic responsible for generating report for university worker  
                break;
            default:
                throw Error('Not supported report type')
        }        // create formatted report  
        return formattedReport;
    }

    public generateAndSendReport(email: string, type: ReportType): Promise<void> {
        const preparedReport: HTMLElement = this.formatReport(type);
        void SaveFileService.saveFile(preparedReport);
        void UniversityEmailService.sendEmail(email, preparedReport);
        return Promise.resolve();
    }
}  

Kod

SRPBadUml

Diagram UML

Powyższa klasa nie stosuje zasad SOLID i ma parę błędów, które poniżej wypunktuję:

  • generateReport → nie jest odpowiedzialny tylko za generowanie raportów, ale również za automatyczne wysyłanie tych raportów e-mailem. Jest to słabe rozwiązanie, ponieważ, jeżeli osoba generująca raportu ustawi sobie jakąś regułę, która będzie automatycznie generować raport dla wszystkich grup studentów (powiedzmy cyklicznie codziennie), wtedy prawdopodobnie zawalimy komuś skrzynkę, a może nawet wyślemy dane, które nie powinny być wysłane.

Dobre podejście

class StudentsReport {
    private report: Map<number, Student> = new Map<number, Student>();

    constructor(students: StudentData[]) {
        this.createReport(students);
    }

    private formatReport(type: ReportType): HTMLElement {
        const formattedReport = document.createElement('table');
        switch (type) {
            case ReportType.DEAN:
                // Logic responsible for generating report for dean  
                break;
            case ReportType.LECTURER:
                // Logic responsible for generating report for lecturer  
                break;
            case ReportType.UNIVERSITY_WORKER:
                // Logic responsible for generating report for university worker  
                break;
            default:
                throw Error('Not supported report type')
        }        // create formatted report  
        return formattedReport;
    }

    private createReport(students: StudentData[]): void {
        // create report logic  
    }

    public getReport(type: ReportType): HTMLElement {
        return this.formatReport(type);
    }
}

const studentsReport: StudentsReport = new StudentsReport([]);
const report: HTMLElement = studentsReport.getReport(ReportType.DEAN);
void GenerateReportService.generateReport('./path', report);
void UniversityEmailService.sendEmail('test@test.pl', report);  

Kod

SRPGoodUML

Diagram UML

Po refaktoryzacji możemy zauważyć, że StudentsReport odpowiada w tym momencie tylko za utworzenie odpowiednio sformatowanego raportu, który zostanie utworzony przez jeden z formaterów. Może i metoda odpowiadająca za formatowanie składa się z wielu ifów, ale odpowiada tylko za jedną rzecz, mianowicie formatowanie raportu. Generowanie raportu na dysk oraz wysyłanie raportu jest realizowane przez dwie niezależne od siebie klasy. Takie klasy są teraz odpowiedzialne tylko za jedną rzecz oraz mają tylko jeden powód do zmiany

O: Open-Closed Principle(OCP)

Elementy oprogramowania powinny być otwarte na rozszerzanie ale zamknięte na modyfikacje

Ryzyko zmiany istniejącej klasy polega na tym, że możemy nieumyślnie wprowadzić zmianę w logice oraz zachowaniu klasy. Rozwiązaniem tego problemu jest stworzenie klasy,
która przesłoni logikę działania oryginalnej klasy. Wykorzystując tę zasadę, będzie znacznie łatwiej utrzymywać napisany kod (nieoczekiwane zmiany w logice, bugi...) oraz reużywać już raz stworzonego kodu.

Złe podejscie

class StudentsReport {
    private report: Map<number, Student> = new Map<number, Student>();

    constructor(students: StudentData[]) {
        this.createReport(students);
    }

    private formatReport(type: ReportType): HTMLElement {
        const formattedReport = document.createElement('table');
        switch (type) {
            case ReportType.DEAN:
                // Logic responsible for generating report for dean  
                break;
            case ReportType.LECTURER:
                // Logic responsible for generating report for lecturer  
                break;
            case ReportType.UNIVERSITY_WORKER:
                // Logic responsible for generating report for university worker  
                break;
            default:
                throw Error('Not supported report type')
        }        // create formatted report  
        return formattedReport;
    }

    private createReport(students: StudentData[]): void {
        // create report logic  
    }

    public getReport(type: ReportType): HTMLElement {
        return this.formatReport(type);
    }
}

Kod

OCPBadUML

Diagram UML

Podejście to ma parę wad, które wymienię poniżej:

  • formatReport → formatuje raport w zależności, dla kogo ma być on stworzony. Co w wypadku gdy będziemy chcieli dodać raport dla studenta lub innych użytkowników? Funkcja wraz z całą klasą urośnie, do ogromnych rozmiarów, co przyczyni się do tego, że kod będzie trudny w czytaniu, ciężko będzie się go utrzymywać i testować. Ponadto zmiany w tej jednej funkcji, mogą mieć wpływ na całą klasę, z czym wiąże się prawdopodobne wygenerowanie błędów.

Dobre podejście

class StudentsReport {
    private report: Map<number, Student> = new Map<number, Student>();

    constructor(students: StudentData[]) {
        this.createReport(students);
    }

    private createReport(students: StudentData[]): void {
        // create report logic  
    }

    public getReport(type: ReportType): HTMLElement {
        const reportFormatter: AbstractReportFormatterService = ReportFormatterServiceFactory.getReportFormatter(type);
        return reportFormatter.formatReport();
    }
}  
  
class ReportFormatterServiceFactory {
    public static getReportFormatter(type: ReportType): DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService {
        let formatterService: DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService;
        switch (type) {
            case ReportType.DEAN:
                formatterService = new DeanReportFormatterService();
                break;
            case ReportType.LECTURER:
                formatterService = new LecturerReportFormatterService();
                break;
            case ReportType.UNIVERSITY_WORKER:
                formatterService = new UniversityWorkerReportFormatterService();
                break;
            default:
                throw Error('Not supported report type')
        }
        return formatterService;
    }
}

Kod

OCPGoodUML

Diagram UML

Każdy formatter otrzymał swoją własną klasę, dzięki czemu jeżeli powstanie jakiś nowy typ użytkownika to wystarczy dla niego utworzyć nową klasę i dodać przypadek w fabryce.

L: Liskov Substitution Principle (LSP)

Klasy potomne nigdy nie powinny łamać definicji typów klas nadrzędnych

Klasa dziedzicząca z klasy podstawowej powinna jedynie rozszerzać funkcjonalność (subklasa nie powinna modyfikować zachowania klasy podstawowej np.: ilość przyjmowanych argumentów konstruktora / funkcji) klasy bazowej oraz zwracać ten sam typ danych. Oznacza to, że w miejscu klasy bazowej powinniśmy być w stanie skorzystać z dowolnej klasy po niej dziedziczącej.

Złe podejście

class ReportFormatterServiceFactory {
    public static getReportFormatter(type: ReportType): DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService {
        let formatterService: DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService;
        switch (type) {
            case ReportType.DEAN:
                formatterService = new DeanReportFormatterService();
                break;
            case ReportType.LECTURER:
                formatterService = new LecturerReportFormatterService();
                break;
            case ReportType.UNIVERSITY_WORKER:
                formatterService = new UniversityWorkerReportFormatterService();
                break;
            default:
                throw Error('Not supported report type')
        }
        return formatterService;
    }
}

Kod

LSPBadUML

Diagram UML

Po przeanalizowaniu kodu możemy zauważyć, że każdy z formatterów ma swój osobny typ i nie są ze sobą powiązane, czyli nie możemy skorzystać z innego formattera w miejscu, w którym już na jakiś się zdecydowaliśmy

Dobre podejście

class ReportFormatterServiceFactory {
    public static getReportFormatter(type: ReportType, report: Map<number, Student>): IHandleFormatterService {
        let formatterService: IHandleFormatterService;
        switch (type) {
            case ReportType.DEAN:
                formatterService = new DeanReportFormatterService(report);
                break;
            case ReportType.LECTURER:
                formatterService = new LecturerReportFormatterService(report);
                break;
            case ReportType.UNIVERSITY_WORKER:
                formatterService = new UniversityWorkerReportFormatterService(report);
                break;
            default:
                throw Error('Not supported report type')
        }
        return formatterService;
    }
}

abstract class AbstractReportFormatterService implements IHandleFormatterService {
    private report: Map<number, Student>;

    public abstract formatReport(): HTMLElement;

    constructor(report: Map<number, Student>) {
        this.report = report;
    }
}

class DeanReportFormatterService extends AbstractReportFormatterService implements IHandleFormatterService {
    public formatReport(): HTMLElement {
        const formattedReport: HTMLElement = document.createElement('table');
        // Report formatting logic
        return formattedReport;
    }
}

Kod

LSPGoodUML

Diagram UML

Utworzona wspólna klasa abstrakcyjna, w której można zawrzeć powtarzającą się logikę oraz dodatkowo zapewniliśmy, to każdy formatter może być podmieniony przez inny, który także dziedziczy po AbstractReportFormatterService

I: Interface Segregation Principle (ISP)

Użytkownik nie powinien musieć polegać na interfejsacg, których nie używa

Często jest tak, że interfejs jest opisem całej klasy. ISP to zasada, która mówi, że klasa powinna być opisana szeregiem mniejszych interfejsów (bardziej szczegółowych odpowiedzialnych tylko za jedną rzecz SRP),
które udostępniają tylko niektóre zasoby klasy zamiast całej jej zawartości w jednym miejscu.

Historyjka:

Doktor Jan Kowalski zgłosił potrzebę aktualizacji raportów dla pojedynczych studentów oraz grup.

Złe podejście

interface IHandleFormatterService {
    formatReport(): HTMLElement;
    updateReport(indexes: number[]): void;
}

abstract class AbstractReportFormatterService implements IHandleFormatterService{
    private report: Map<number, Student>;

    protected formattedReport: HTMLElement;

    public abstract formatReport(): HTMLElement;
    public abstract updateReport(indexes: number[]): void;

    constructor(report: Map<number, Student>) {
        this.report = report;
    }
}

class UniversityWorkerReportFormatterService extends AbstractReportFormatterService implements IHandleFormatterService {
    public formatReport(): HTMLElement {
        const formattedReport: HTMLElement = document.createElement('table');
        // Report formatting logic
        return formattedReport;
    }

    public updateReport(indexes: number[]): void {
        // update report logic
    }
}

Kod

ISPBadUML

Diagram UML

Nie powinniśmy rozszerzać głównego interfejsu, z którego korzysta każda grupa użytkowników, ponieważ:

  • Za każdym razem gdy dołożymy kolejny element do interfejsu IHandleFormatterService, będziemy musieli go zaimplementować w każdej klasie, która go implementuje (co najwyżej jeżeli będzie to element opcjonalny), a niekoniecznie go potrzebuje
  • updateReport nie powinno być dostępnie dla pani sekretarki, które pracuje na uczelni, ponieważ nie powinna ingerować w raporty studentów podległych jakiemuś doktorow

Dobre podejście

type IHandleReportFormatterService = IDeanReportFormatterService | ILecturerReportFormatterService | IUniversityWorkerReportFormatterService;
interface IBaseReportFormatterService {
   formatReport(): HTMLElement;
}
interface UniversityAdministrator {
   updateReport(indexes: number[]): void;
}
interface IDeanReportFormatterService extends IBaseReportFormatterService { }
interface ILecturerReportFormatterService extends IBaseReportFormatterService, UniversityAdministrator {}
interface IUniversityWorkerReportFormatterService extends IBaseReportFormatterService { }

abstract class AbstractReportFormatterService implements IBaseFormatterService {
   private report: Map<number, Student>;

   protected formattedReport: HTMLElement;

   public abstract formatReport(): HTMLElement;

   constructor(report: Map<number, Student>) {
       this.report = report;
   }
}

export class LecturerReportFormatterService extends AbstractReportFormatterService implements ILecturerFormatterService {
   public formatReport(): HTMLElement {
       const formattedReport: HTMLElement = document.createElement('table');
       // Report formatting logic
       return formattedReport;
   }


   public updateReport(indexes: number[]): void {
       // Update report logic
   }
}

Kod

ISPGoodUML

Diagram UML

Interfejs IHandleFormatterService stał się typem union, który jest zbiorem interfejsów. Każdy typ użytkownika ma swój własny interfejs, dzięki czemu jeżeli trzeba dołożyć jakąś funkcjonalność / pole dla tylko jednej grupy użytkowników to wystarczy zrobić to tylko dla nich, a nie dla wszystkich. Dodatkowo wyodrębniliśmy wspólny interfejs, który zawiera deklarację implementacji metody formatReport, która jest wspólna dla wszystkich typów użytkowników i każdy musi ją zaimplementować.

D: Dependency Inversion Principle (DIP)

Abstrakcja nie powinna zależeć od detali. Detale powinny zależeć od abstrakcji

Abstrakcja lub moduł niższego poziomu nie powinien być zależny od klas czy modułów wyższego poziomu, ponieważ zmiany, które będą wymagane do wprowadzenia w bardziej wysokopoziomowej klasie prawdopodbnie
będą miały wpływ na niskopoziomową klasę / moduł, przez co wymagane będą zmiany w klasach / modułach, które korzystają te bardziej niskopoziomową

Historyjka:

Dziekan uczelni podpisał kontrakt z Microsoft na dostarczenie serwisu mailingowego. Musieliśmy stworzyć nowy serwis mailingowy, po czym okazało się, że zerwał kontrakt z Microsoft i podpisał kontrakt z Amazon. Wymusiło to, że znowu trzeba zmienić serwis odpowiedzialny za wysłanie wiadomości email. Po paru miesiącach dziekan dostał ofertę od Google i ją przyjął oraz zerwał kontrakt z Amazon. Po raz kolejny musimy zmienić nas serwis mailingowy.

Złe podejście

class UniversityEmailService {
    public sendEmail(email: string, data: unknown): Promise<void> {
        // Send University email logic
        return Promise.resolve();
    }
}

class AzureEmailService {
    public sendEmail(email: string, data: unknown): Promise<void> {
        // Send Azure email logic
        return Promise.resolve();
    }
}

class AmazonEmailService {
    public sendEmail(email: string, data: unknown): Promise<void> {
        // Send Amazon email logic
        return Promise.resolve();
    }
}

class GoogleEmailService {
    public sendEmail(email: string, data: unknown): Promise<void> {
        // Send Google email logic
        return Promise.resolve();
    }
}

Code

DIPBadUML

Diagram UML

Podejście to jest złe, ponieważ za każdym razem musimy zmieniać serwis odpowiedzialny za wysyłanie emaili.

Dobre podejście

type IHandleEmailService = IAzureEmailService | IAmazonEmailService | IGoogleEmailService;
interface IBaseEmailService {
    sendEmail(email: string): Promise<void>;
}
interface IAzureEmailService extends IBaseEmailService { }
interface IAmazonEmailService extends IBaseEmailService { }
interface IGoogleEmailService extends IBaseEmailService { }

abstract class AbstractEmailService implements IBaseEmailService {
    protected data: unknown;

    public abstract sendEmail(): Promise<void>;

    constructor(data: unknown) {
        this.data = data;
    }
}

class GoogleEmailService extends AbstractEmailService implements IGoogleEmailService {
    public sendEmail(email: string): Promise<void> {
        // Send email logic
        return Promise.resolve();
    }
}

DIPGoodUML

Diagram UML

W tym podejściu została wyodrębniona klasa abstrakcyjna, po której dziedziczy każdy unikalny serwis do wysyłania emaili. Dzięki temu rozwiązaniu można rozwiązać problem zależności modułu wyższego poziomu, od modułu niższego poziomu. Dodatkowo nie będzie potrzeby modyfikowania funkcjonalności odpowiedzialnej za wysyłanie emaili, wystarczy, że zmienimy serwis, z którego korzystamy.

Bibliografia: