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/
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';
// 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);
}
}
}
// core/arch/use-case.ts
export interface IUseCase<TRequest, TPresenter> {
readonly presenter: TPresenter;
execute(request: TRequest): Promise<void>;
}
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,
) {
}
}
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>;
}
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>;
}
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;
}
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;
}
}
}
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;
}
}
}
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;
}
}
}
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;
}
}
}
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)