DEV Community

Julian Finkler
Julian Finkler

Posted on • Edited on

Clean Architecture in TypeScript Projekt: Geschäftslogik

Vorweg: In diesem Beitrag geht es nicht darum Clean Architecture zu erklären, sondern wie meine Erfahrung in einem TypeScript Projekt waren und wie man die Architektur in einem TypeScript bzw. später Angular Projekt implementieren kann.



title: "Clean Architecture in einem TypeScript Projekt"

series: Clean Architecture in Typescript

Dieses Jahr habe ich mich ziemlich viel mit Architekturen und Design Patterns auseinander gesetzt.
Irgendwann Mitte des Jahres bin ich auf Clean Architecture von Robert C. Martin gestoßen und war über den Lösungsansatz ziemlich begeistert.

Alles easy going... nicht

Als begeisterter Programmierer wollte ich natürlich das ganze Konzept nun in meine Projekte einbauen. Somit fing ich an, mir das ganze einmal näher anzuschauen. Alles schien auf den ersten Blick im Diagramm ganz logisch und einfach umsetzbar. Es gibt verschiedene Schichten (wie bei der Zwiebel-Architektur), wobei die jeweilige Schicht nur Code in der eigenen Schicht und den inneren Schichten kennt, jedoch nichts äußeres - auch keine Referenzen, Imports etc.. Grundsätzlich lässt sich das mit dem DIP lösen.

Und dann ging es langsam mit den Fragen wie "Wer erzeugt die Instanzen meiner Klassen?" oder "Wie strukturiere ich meinen Code am besten?" los.

Tatsächlich brauchte es 4-6 Wochen bis ich das ganze Konzept hinter Clean Architecture verstanden habe und noch mal zwei weitere Wochen um was halbwegs lauffähiges, was sich an die Architektur hält, zu programmieren.

Eine Architektur lässt sich in der Praxis nie zu 100% umsetzen...

...wenn man Produktiv bleiben möchte. 🙂

Ich habe versucht, die Architektur haargenau in einem Angular Projekt umzusetzen. Das Ergebnis war ernüchternd. Die ganzen Vorteile die geschaffen wurden, wurden durch einen Slowdown überdeckt. Viele neue Dateien, welche zum Teil nur Datenstrukturen abbilden und keine vernünftige Ordnerstruktur (Screaming Architecture; ist meiner Meinung nach in Kombination mit Clean Architecture der Obergau) machten ein produktives arbeiten im Projekt unmöglich.
Also hieß es git reset --hard origin/master

Grundstruktur definieren

Durch die Erkenntnis, dass es viele neue Dateien geben wird, wollte ich nun zuerst einmal die Ordnerstruktur festlegen. Diese verwende ich mittlerweile überwiegend, da sie für mich gut funktioniert.

Root
|- core/
|  |- entity/
|  |- repository/
|  |- use-case/
|- data/
|- infrastructure/
|- presentation/
Enter fullscreen mode Exit fullscreen mode

core beinhaltet alles an Business Logic. Dieser Ordner repräsentiert also die beiden innersten Layer. Ich könnte problemlos den Code vom core Verzeichnis von einem Angular Projekt in ein Vue Projekt kopieren und müsste darin nichts ändern um die Businesslogik zu übernehmen.

In data werden Datenmodelle, welche z.B. für den Austausch mit einer API benötigt werden, gespeichert. Außerdem befinden sich in diesem Ordner auch die Implementierungen der abstrakten Repositories aus dem Core Verzeichnis.

Im Verzeichnis infrastructure sind z.B. Implementierungen der abstrakten Services, welche im Core benötigt werden, zu finden. Hierbei fällt z.B. ein TranslationService oder ein InteractionService der mit dem Benutzer interagiert.

Ich denke, die Bedeutung vom Verzeichnis presentation ist klar. Hier findet sich alles, was mit UI zu tun hat.

Grundpfeiler: UseCase und Presenter

Ich habe im Verzeichnis core noch einen Ordner arch angelegt. In diesem befinden sich mit den folgenden drei Dateien, der "Basiscode" für die Architektur:

// core/arch/index.ts
export * from './use-case';
export * from './presenter';
Enter fullscreen mode Exit fullscreen mode
// core/arch/presenter.ts
export abstract class Presenter<TView> {
  public viewModel: TView;

  constructor(private template: new() => TView,
  ) {
  }

  public reset(): void {
    const model = new this.template();

    if (this.viewModel == null) {
      this.viewModel = model;
    } else {
      Object.assign(this.viewModel, model);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// core/arch/use-case.ts
export interface IUseCase<TRequest, TPresenter> {
    readonly presenter: TPresenter;

    execute(request: TRequest): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Eine "To Do App" soll es sein

Bei SPAs ist eine Aufgaben App das "Hallo, Welt!" schlecht hin. Also los geht's. (Den Code gibt's auf GitHub)
Zunächst schreiben wir nur Business Logic, später wird diese in einer Angular Anwendung verwendet.

Erst einmal Use-Cases definieren

Die Use-Cases einer To Do App sind relativ überschaubar:

  • Liste der Aufgaben anzeigen
  • Aufgabe hinzufügen
  • Aufgabe bearbeiten
  • Aufgabe löschen
  • Aufgabe abhaken
  • Aufgabe abhaken rückgängig machen

Da wir Informatiker "bequem" sind, reichen uns auch vier Use-Cases

  • Liste der Aufgaben anzeigen
  • Aufgabe hinzufügen
  • Aufgabe bearbeiten (abgehakt ist eine Boolesche Variable 🤓)
  • Aufgabe löschen

Entity erstellen

Weiter geht's mit dem Model für eine Aufgabe. Um es einfach zu halten, gibt es nur eine Beschreibung und ein "Erledigt" Flag:

// core/entity/to-do.ts
export class ToDo {
    constructor(public description: string,
                public isDone: boolean = false,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

Repository anlegen

Damit Aufgaben geladen, gespeichert und gelöscht werden können, wird ein Repository benötigt. Da wir uns zunächst im Core bewegen und im nächsten Schritt erst unsere Use Cases implementieren, reicht es eine abstrakte Klasse mit vier abstrakten Methoden anzulegen. Warum bei editToDo und deleteToDo eine id benötigt wird und woher die kommt, erkläre ich später.

// core/repository/todo.repository.ts
import {ToDo} from '../entity';

export abstract class TodoRepository {
    public abstract getAllToDos(): Promise<ToDo[]>;

    public abstract createToDo(todo: ToDo): Promise<ToDo>;

    public abstract editToDo(id: number, todo: ToDo): Promise<ToDo>;

    public abstract deleteToDo(id: number): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Services anlegen

Damit der User nicht aus versehen eine Aufgabe löscht, muss nach dem Klick auf den löschen Button das löschen zuerst bestätigen. Um das einheitlich abzubilden schreiben wir einen abstrakten interaction.service.ts unter core/service (Verzeichnis anlegen😉). Diese Klasse enthält eine abstrakte Methode confirm, welche einen string als Input bekommt (Die Nachricht die bestätigt werden soll) und ein Promise<boolean> mit dem Ergebnis zurück gibt.
Außerdem enthält die Klasse noch eine abstrakte Methode enterString. Du kannst dir sicherlich denken wofür. Richtig, zum Bearbeiten der Aufgaben Beschreibung.

// core/service/interaction.service.ts
export abstract class InteractionService {

    public abstract confirm(message: string): Promise<boolean>;

    public abstract enterString(currentValue?: string): Promise<string>;
}

Enter fullscreen mode Exit fullscreen mode

Use Cases

Nun geht's ans Eingemachte. Im Verzeichnis core/use-case wird nun die Datei show-to-do-list.use-case.ts angelegt. In dieser wird der UseCase zum Darstellen der Liste mit den bestehenden Aufgaben programmiert. Der UseCase selber lädt die Aufgaben aus dem TodoRepository und stellt diese mithilfe des Presenters dar.

Starten wir mit dem (abstrakten) List Presenter, dieser leitet von Presenter<T> ab, wobei das Typ Argument 1:1 durchgereicht wird. Es wird als nächstes noch eine abstrakte Methode benötigt, welche die geladenen Aufgaben entgegen nimmt.

// core/use-case/show-to-do-list.use-case.ts
import {IUseCase, Presenter} from '../arch';
import {ToDo} from '../entity';
import {TodoRepository} from '../repository';

export abstract class ShowToDoListPresenter<T> extends Presenter<T> {
    public abstract displayToDos(toDos: ToDo[]): void;
}
Enter fullscreen mode Exit fullscreen mode

Als nächstes wird der eigentliche UseCase geschrieben. Dieser leitet vom IUseCase<T1, T1> ab. Wobei als T1 void übergeben wird, weil es ja keine request im eigentlichen Sinne gibt und als T2 der ShowListPresenter<any> übergeben wird.

Im constructor wird später der konkrete Presenter und das konkrete TodoRepository injected.

Die execute Methode ist relativ einfach gestrickt. Hier werden zuerst alle Aufgaben aus dem repository geladen und anschließend an den Presenter weitergegeben.

// core/use-case/show-to-do-list.use-case.ts
export class ShowToDoListUseCase implements IUseCase<void, ShowToDoListPresenter<any>> {

    constructor(public readonly presenter: ShowToDoListPresenter<any>,
                private readonly repository: TodoRepository,
    ) {
    }

    public async execute(request: void): Promise<void> {
        try {
            const allToDos = await this.repository.getAllToDos();
            this.presenter.displayToDos(allToDos);
        } catch (e) {
            console.error('Failed to load or present to dos: %o', e);
            throw e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Neue Aufgabe anlegen

Der nächste UseCase der implementiert werden soll, ist add-to-do.use-case.ts. Auch hier implementieren wir wieder den IUseCase jedoch mit zwei mal void als Typ, da wir keine Request und auch keinen Presenter haben. Stattdessen injecten wir einfach den ShowToDoListUseCase und führen diesen aus, nachdem die Aufgabe hinzugefügt wurde

// core/use-case/add-to-do.use-case.ts
import {IUseCase} from '../arch';
import {ShowToDoListUseCase} from './show-to-do-list-use.case';
import {InteractionService} from '../service';
import {TodoRepository} from '../repository';
import {ToDo} from '../entity';

export class AddToDoUseCase implements IUseCase<void, void> {
    public readonly presenter: void;

    constructor(private readonly interaction: InteractionService,
                private readonly repository: TodoRepository,
                private readonly listUseCase: ShowToDoListUseCase,
    ) {
    }

    public async execute(request: void): Promise<void> {

        try {
            const description = await this.interaction.enterString();
            if (description == null || description.trim() === '') {
                return;
            }

            const todo = new ToDo(description);
            await this.repository.createToDo(todo);

            await this.listUseCase.execute();
        } catch (e) {
            console.error('Failed to create a todo: %o', e);
            throw e;
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

Aufgabe bearbeiten

Zum bearbeiten von Aufgaben erstellst du den edit-to-do.use-case.ts.
In diesem Fall leitet der UseCase wieder von IUseCase ab. Als Request Typen wird EditToDoRequest und als Presenter Typen void übergeben.

Die Request Klasse hat drei Felder, die id der Aufgabe, das todo selber und ein Flag onlyToggleDone welches angibt, ob die Aufgabe lediglich als erledigt / nicht erledigt markiert werden soll.

In den UseCase selber wird wieder der InteractionService, das TodoRepository und der ShowToDoListUseCase injected.

Im execute wird zunächst geprüft, ob lediglich der Status der Aufgabe geändert werden soll. Ist das der Fall wird das isDone Flag der Aufgabe umgekehrt. Falls nicht, wird solange nach einer neuen Aufgaben Beschreibung gefragt, bis der User die Eingabe abbricht (null) oder mindestens ein nicht Whitespace Zeichen eingibt.

Anschließend wird in beiden Fällen die Aufgabe mit dem Repository gespeichert und der ShowToDoListUseCase ausgeführt, um die Liste zu aktualisieren.

// core/use-case/edit-to-do.use-case.ts
import {IUseCase} from '../arch';
import {ToDo} from '../entity';
import {InteractionService} from '../service';
import {TodoRepository} from '../repository';
import {ShowToDoListUseCase} from './show-to-do-list.use-case';

export class EditToDoRequest {

    constructor(public readonly id: number,
                public readonly todo: ToDo,
                public readonly onlyToggleDone: boolean = false,
    ) {
    }
}

export class EditToDoUseCase implements IUseCase<EditToDoRequest, void> {
    readonly presenter: void;

    constructor(private readonly interaction: InteractionService,
                private readonly repository: TodoRepository,
                private readonly listUseCase: ShowToDoListUseCase,
    ) {
    }

    public async execute(request: EditToDoRequest): Promise<void> {
        try {
            const todo = new ToDo(request.todo.description, request.todo.isDone);
            if (request.onlyToggleDone) {
                todo.isDone = !todo.isDone;
            } else {
                do {
                    todo.description = await this.interaction.enterString(todo.description);
                    if (todo.description == null) {
                        return;
                    }
                } while (todo.description.trim() == '')
            }

            await this.repository.editToDo(request.id, todo);

            await this.listUseCase.execute();
        } catch (e) {
            console.error('Failed to edit a todo: %o', e);
            throw e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Aufgabe löschen

Zuletzt fehlt nun noch der Use Case zum löschen einer Aufgabe.
In diesem UseCase wird als Request Typ eine Zahl, nämlich die ID der Aufgabe übergeben.
Der Konstruktor sollte mittlerweile klar sein.

Beim execute wird der Nutzer zunächst gefragt, ob die Aufgabe gelöscht werden soll. Beantwortet der diese Frage mit false, wird die Ausführung des UseCase abgebrochen.
Andernfalls wird die Aufgabe mithilfe des TodoRepository gelöscht und die Liste der Aufgaben mit dem ShowToDoListUseCase aktualisiert.

// core/use-case/delete-to-do.use-case.ts
import {IUseCase} from '../arch';
import {InteractionService} from '../service';
import {TodoRepository} from '../repository';
import {ShowToDoListUseCase} from './show-to-do-list.use-case';

export class DeleteToDoUseCase implements IUseCase<number, void> {
    readonly presenter: void;

    constructor(private readonly interaction: InteractionService,
                private readonly repository: TodoRepository,
                private readonly listUseCase: ShowToDoListUseCase,
    ) {
    }

    public async execute(id: number): Promise<void> {
        try {
            if (!await this.interaction.confirm('Soll die Aufgabe gelöscht werden?')) {
                return;
            }

            await this.repository.deleteToDo(id);

            await this.listUseCase.execute();
        } catch (e) {
            console.error('Failed to delete a todo: %o', e);
            throw e;
        }
    }

}

Enter fullscreen mode Exit fullscreen mode

Zusammenfassung

Du hast nun die komplette Business Logik unserer Aufgaben App programmiert.
Wie du sicherlich festgestellt hast, wurde noch keinen Gedanke an die Datenhaltung oder an das UI verschwendet, und das ist gut so.
Der Code ist so allgemein wie möglich gehalten, dass es sich hierbei um eine Konsolen-Anwendung oder eine Browser Anwendung handeln könnte.
Erst mit der Implementierung der Services und der Controller wird definiert, wo die Anwendung später laufen wird, wobei alles "drum-herum" als "Plugins" für deine Anwendung zu verstehen ist. Die Datenbank ist ein Plugin, das UI ist ein Plugin und auch ein Framework ist lediglich ein Plugin. Programmiere nicht nach einem Framework sondern passe das Framework an, sodass es mit deiner Anwendung funktioniert 😉 (z.B. per Wrapper Klassen).

Danke für's lesen, ich hoffe es war verständlich bis hier hin.

Im zweiten Teil des Beitrags geht es darum, die Services zu implementieren und mit Angular die Anwendung zum laufen zu bringen.

Top comments (0)