Z jednej strony wiemy o encji będącej reprezentacją wiersza z tabeli bazy, a z drugiej o miejscu na logikę biznesu. Encja może mieć tylko zmapowane pola bazy czy coś więcej? Dzisiaj o tym, kiedy lepiej mieć encje bogate, a kiedy anemiczne.
Entity
Pojęcie encji może być rozpatrywane na kilka sposobów. Większości z nas słowo encja kojarzy się głównie z bazą danych. Jest to poprawne skojarzenie, ponieważ encja jest odwzorowaniem identyfikowalnego obiektu w modelu, np. bytem reprezentującym wiersz z tabeli bazodanowej. Encje opisywane są atrybutami i co najważniejsze posiadają tożsamość. Poprzez tożsamość rozumie się pewną unikatową wartość lub grupę wartości, która pozwala zidentyfikować konkretną instancję na tle zbioru. Podczas omawiania konceptu Value Object wspominałem, że nie posiadają one tożsamości, czyli wykluwa nam się już jedna z głównych różnic pomiędzy tymi wzorcami.
Model anemiczny (anemic model)
Wspomniałem jednak o rozpatrywaniu encji na kilka sposobów. W istocie, możemy patrzeć na ten koncept z kilku różnych płaszczyzn. Przywykliśmy chyba do tego, że nasza encja w kodzie posiada skończony zbiór właściwości oraz propertkę tożsamującą, np. uuid. Dodatkowo, jeżeli używamy ORM, to uświadczymy mechanizmy bindujące konkretne pola klasy z polami w bazie danych, a także jeśli encja posiada relacje, to zobaczymy pola zawierające złączone dane. Może jeszcze jakiś setter, getter, a także konstruktor? Jeżeli tak wyglądają Twoje encje to znaczy, że używasz tzw. encji anemicznych.
Reprezentacja encji sprowadzająca się jedynie do mapowania wartości: propertka klasy – pole w bazie, a operacje biznesowe na tych danych są wyniesione poza samą encję, np. do serwisów lub (co gorsza) do kontrolerów, powoduje, że narażamy się na posiadanie trudnego w utrzymaniu kodu, brak dobrej widoczności reguł biznesu czy zwyczajnie: niepoprawne rozmieszczenie odpowiedzialności biznesowej.
@Entity('meals')
export class Meal {
@PrimaryColumn()
id: string;
@Column({ type: 'enum', enum: MealType })
type: MealType;
@Column({ type: 'enum', enum: MealStatus })
status: MealStatus;
setStatus(status: MealStatus): void {
this.status = status;
}
}
export class MealsService {
async changeMealStatus(mealId: string, status: MealStatus): Promise<void> {
const meal = await this.mealRepository.findOneOrFail(mealId);
if (meal.type === MealType.breakfast && status === MealStatus.skipped) {
throw new RuntimeException('Cannot skip the most important meal!');
}
if (meal.status === status) {
throw new RuntimeException('Already ${status}.');
}
if (meal.status !== MealStatus.new) {
throw new RuntimeException('Already ${meal.status}.');
}
meal.setStatus(status);
await this.mealRepository.save(meal);
}
}
Model bogaty (rich model)
Poprawne miejsce na logikę zmiany statusu w powyższym przykładzie nie jest w serwisie, a w obiekcie reprezentującym konkretny posiłek. W tym przypadku to posiłek wie czy może zostać pominięty, czy może zostać zjedzony. Wyodrębnienie tej logiki do innego miejsca skutkuje mało przejrzystym interfejsem, niepotrzebną granulacją odpowiedzialności biznesowej czy ogólnym ukryciem skutków polecenia changeMealStatus(), mógłbym tak wymieniać i wymieniać.
Lekarstwem na anemię jest… bogactwo. No, może nie w prawdziwym życiu, ale w przypadku encji na pewno. Czym jest to tzw. bogactwo i model bogaty? Jest to posiadanie logiki związanej stricte z daną encją w niej samej. Mamy wtedy wysoką spójność, brak problemów z interfejsem i reguły biznesu są tam, gdzie powinny być. Zaraz, zaraz, a co z serwisami? Nie potrzebujemy ich? Potrzebujemy, ale ich zadanie troszeczkę się zmienia i o serwisach w kontekście bardziej domenowym powiem już wkrótce, a powyższy przykład możemy poprawić następująco:
@Entity('meals')
export class Meal {
@PrimaryColumn()
private id: MealId;
@Column({ type: 'enum', enum: MealType })
private type: MealType;
@Column({ type: 'enum', enum: MealStatus })
private status: MealStatus;
// entity constructor()
eat(): void {
if (this.status !== MealStatus.new) {
throw new RuntimeException('Cannot eat already ${this.status} meal.');
}
this.status = MealStatus.eaten;
}
skip(): void {
if (this.status !== MealStatus.new) {
throw new RuntimeException('Cannot skip already ${this.status} meal.');
}
if (this.type === MealType.breakfast) {
throw new RuntimeException('Cannot skip the most important meal!');
}
this.status = MealStatus.skipped;
}
}
export class MealsService {
async skipMeal(mealId: string): Promise<void> {
const meal = await this.mealRepository.findOneOrFail(mealId);
meal.skip();
await this.mealRepository.save(meal);
}
async eatMeal(mealId: string): Promise<void> {
const meal = await this.mealRepository.findOneOrFail(mealId);
meal.eat();
await this.mealRepository.save(meal);
}
}
Czy anemic entity jest zawsze złe?
Dochodzimy do sedna sprawy encji. Pytanie nad którym głowią się wszyscy filozofowie świata: encja bogata czy anemiczna? Dawno nie pisałem, że to zależy, ale dzisiaj to zależy nie od frameworka czy technologii, ale od problemu, który musisz rozwiązać. Jeśli implementujesz CRUD-a, to nie ma najmniejszego sensu tworzenia modeli bogatych, bo i po co? W przypadku prostych krudzików nie ma na tyle (albo i żadnej) logiki związanej stricte z encją. Sprawa ma się inaczej, jeżeli na Twoich encjach zachodzą operacje takie, jak np. ta powyżej. Im więcej logiki związanej z danym modelem, tym bardziej powinieneś/powinnaś zastanowić się nad modelem bogatym.
Jeśli chcesz wiedzieć jaki model powinien zostać zastosowany ZANIM zaczniesz kodować, to zachęcam Cię do zaznajomienia się z koncepcją Domain Driven Design, o której już niejednokrotnie wspominałem.
Podsumowanie
- Możemy wyróżnić dwa główne style tworzenia encji: bogaty i anemiczny.
- Zachęcam do spróbowania koncepcji modeli bogatych, jeśli Twój kod cechuje się realizowaniem czegoś więcej niż CRUD.
- Encje to nie tylko sposób na odzwzorowanie wiersza z tabeli bazodanowej, ale także szansa na lepsze przedstawienie reguł biznesowych.
Dodaj komentarz