Anemic Domain Model vs Rich Domain Model в Spring
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Коли я тільки починав свій шлях програміста я знав всього кілька основних шаблонів проєктування. Тоді я думав, що досвідчені розробники їх використовують кожен день, навіть пробував їх сам ліпити там де не треба, за що і отримував по руках. З часом я змінив декілька команд, потім перейшов в іншу фірму, але широкого використання патернів я не побачив ніде. Звісно мова не йде про Builder, Singleton, Abstract Fabric, а про більш складні і менш розповсюджені шаблони. Знайома ситуація?
Щоб повністю зрозуміти проблему — треба почати з основ.
Low coupling and High cohesion говорять нам, що кожен клас не має мати багатьох залежностей від інших класів (Low coupling), але кожен клас має містити тільки ті методи і поля, які йому необхідні для роботи і виконувати тільки одну конкретну задачу (High cohesion, Single responsibility). Якщо весь код помістити в один клас — це буде Low coupling, але не буде High cohesion.
Як виглядають всі типові проекти написані на Spring? Є класична трьохшарова аплікація. Контроллер приймає виклик якогось ендпоінта, далі він викликає якийсь метод сервісу з присланими параметрами в DTO, в сервісі робиться бізнес логіка, створюється domain об’єкт і кладеться в базу (або дістається\видаляється).
Як нас вчать офіційні гайди Спрінга — всі доменні об’єкти не мають жодних методів окрім гетерів\сетерів, а представлені у вигляді POJO. Багато авторів (такі як Мартін Фаулер) вважають це антипатерном і називають його — Anemic Domain Model.
При Anemic domain design, всі логіка програми тримається в шарі бізнес логіки (сервіси, BO). Тоді доменні об’єкти не мають методів щоб оперувати своїми полями, а слідом і поведінки, що порушує принципи ООП, патерни GRASP, не дає реалізувати патерни проєктування.
Приклад анемічної моделі:
@Data @Entity @Table(name = "expense", schema = "expenses") public class ExpenseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "price") private Integer price; @Column(name = "comment") private String comment; @Column(name = "date") private LocalDateTime date; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private UserEntity user; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "expense_to_expense_type_dict", joinColumns = @JoinColumn(name = "expense_id"), inverseJoinColumns = @JoinColumn(name = "expense_type_id")) @Fetch(FetchMode.SUBSELECT) private List<ExpenseTypeDictEntity> expenseTypeDict; }
GRASP
Information Expert каже, що методи мають лежати в тому об’єкті поля якого використовуються. Або поля мають належати об’єкту який їх використовує.
Creator каже, що створювати об’єкт класу А має клас Б, який використовує, або має залежності від класу А. Якщо в коді є ланцюг методів, що передають один одному той самий об’єкт — це є порушенням патерну Creator, бо створювати об’єкт треба там де він буде використовуватись. Хоча інколи нам необхідно передавати об’єкт (викликати метод на іншій машині, передати дані з фронту), тоді використовують DTO, що є порушенням парадигми ООП, бо DTO — це об’єкт без поведінки, тому що поведінку неможливо передати через мережу, з цього виходить, що ми свідомо ідемо на цей крок.
Конструктор є методом, тому Creator є уточненням Information expert.
Дотримання цих принципів веде до Low coupling. Недотримання суперечить принципу інкапсуляції (один шматок коду не має лізти своїми брудними руками в інший шматок коду). Навіть якщо в об’єкт передає інформацію через гетери, це все одно інформація. Не правильно передавати і обробляти в іншому місці. З часом систему буде дуже важко змінювати.
Що ми маємо в типовій аплікації Spring? Вся логіка тримається в сервісах, які є сінглтонами, що є варіацією антипатерну Singletonism, що веде до процедурного стилю програмування, коли маємо багато дубльованого коду, методи з багатьма параметрами і локальними змінними, що в свою чергу унеможливлює розбити метод на менші і перевикористати код.
Protected Variations — проблема модифікації системи найбільш актуальна в умовах вимог, що динамічно змінюються. Найчастіше вдається виділити так звані точки нестійкості системи, які найчастіше будуть схильні до зміни / модифікації. Тоді, сутність шаблону «стійкий до змін» полягає в усуненні точок нестійкості, шляхом визначення їх як інтерфейсів і реалізації для них різних варіантів поведінки.
Вирішує проблему, коли зміна одного елементу системи веде до зміни інших елементів.
На практиці це виглядає так: є метод в якому є безліч вкладених IFів. Зміна одного вкладеного оператору веде до зміни поведінки інших зовнішніх. Щоб уникнути цього можна застосувати техніку Replace Conditional with Polymorphism. Замість сотень іфів краще створити сотню класів з поліморфним методом в яких інкапсулювати логіку, що і заохочує Information Expert. Але в анемічній моделі ця логіка буде триматись в сервісах і створювати ці об’єкти буде ніде.
Багато недосвідчених програмістів бояться створювати додаткові класи. Часто можна почути фразу «забагато класів», але якщо великі не розділяти на малі — це буде недотриманням патерну High cohesion.
Число помилок на стрічку коду є константою. На приклад програміст робить 1 помилку на 1000 рядків коду. То на практиці це буде виглядати так, що в методі на 1000 стрічок будуть постійно з’являтись баги які тяжко виправляти. А якщо той метод розбити на багато дрібних класів чи інших методів — там буде кілька стрічок коду в яких не буде тієї помилки і програміст туди без потреби не полізе. Якщо в якомусь класі і виявиться та єдина помилка — виправити її буде дуже легко, після чого ми знову забуваємо про даний клас.
В ідеалі, треба працювати з класами тільки через інтерфейси. Я б радив запозичити ідею з Test Driven Development, тобто створювати спочатку зручний інтерфейс, а потім його реалізовувати. Це дуже добре вплине на якість коду, тому що ми робимо себе клієнтами свого коду і зразу бачимо на скільки зручно використовувати новий метод.
Rich Domain Model
Що якщо спробувати перенести логіку в саму модель, як того і вимагає ООП? Є різні варіації Rich Model.
Приклад доменного об’єкту написаного в стилі Rich Domain Model Extreme:
@Entity public class ExpenseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "price") private Integer price; ... @Autowired // модель має залежність від інфраструктури! private final ExpenseRepo expenseRepo; @Autowired private final AuthenticationBO authenticationBO; @Autowired private final UserRepo userRepo; ... @Transactional public void createExpense(ExpenseDTO expenseDTO) { //модель робить забагато! validateExpense(expenseDTO); ExpenseEntity expenseEntity = new ExpenseEntity(); initExpenseEntity(expenseEntity, expenseDTO); expenseRepo.save(expenseEntity); } private void validateExpense(ExpenseDTO expenseDTO) { ... } private void initExpenseEntity(ExpenseEntity expenseEntity, ExpenseDTO expenseDTO) { UserEntity userEntity = authenticationBO.getLoggedUser(); expenseEntity.setUser(userEntity); expenseEntity.setPrice(expenseDTO.getPrice()); expenseEntity.setComment(expenseDTO.getComment()); expenseEntity.setDate(LocalDateTime.now()); initExpenseTypes(expenseEntity, expenseDTO.getTypes()); } private void initExpenseTypes(ExpenseEntity expenseEntity, List<String> types) { // модель записює в базу інші об'єкти! List<ExpenseTypeDictEntity> expenseTypes = new ArrayList<>(); types.forEach(x -> { ExpenseTypeDictEntity typeDict = expenseTypeDictRepo .findByNameIgnoreCase(x.trim()) .orElseGet(() -> createExpenseTypeDict(x)); typeDict.setUsedCount(typeDict.getUsedCount() + 1); typeDict = expenseTypeDictRepo.save(typeDict); expenseTypes.add(typeDict); }); expenseEntity.setExpenseTypeDict(expenseTypes); } private ExpenseTypeDictEntity createExpenseTypeDict(String name) { ... } }
Як ми бачимо, ми познайомили шар моделі з DI і іншою автомагією, що порушує Layered архітектуру. Крім того, в такому об’єкті може бути забагато методів і полів, які не мають нічого спільного з предметною областю.
Такий стиль є альтернативою, але на мою думку, в доменні класи не треба передавати засоби для роботи з базою даних. На практиці, такий код швидко стане пеклом для тестування і непідтримуваним, тому що завжди стилі програмування в команді дуже відрізняються і велику роль грає рівень досвідченості програмістів. Тому треба знайти певний баланс. На цю тему рекомендую чудову статтю: dzone.com/...jects-finding-the-balance
Що нам важливо, автор рекомендує дотримуватись трьох правил:
- Operations/methods added to a Domain Object should mutate ONLY in the current JVM memory state of that object, and NOT any external state (i.e. state in database).
- Operations in the orchestrating objects (Manager/Service) should NOT break if the state of a Domain Object changes. This can be achieved if they always ask domain objects for answers to domain questions instead of trying to figure out the answers themselves.
- Operations on the Domain Object should ONLY use the in current JVM memory state of the object.
Тепер ми бачимо всю глибину проблеми. Обидва підходи мають серйозні недоліки. Варто спробувати зробити гібрид двох моделей. В якомусь сенсі це буде анемінча модель на стероїдах. В сервісах ми тримаємо логіку доступу до даних які лежать в базі, а в самих доменних об’єктах ми тримаємо методи для роботи з даними конкретного класу. В такому випадку доменні класи виглядатимуть приблизно так:
@Data @Entity @Table(name = "user", schema = "expenses") public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "email") private String email; ... @OneToMany(mappedBy="user") private List<ExpenseEntity> expenses; ... public Integer calculateAllExpensesPrice() { return expenses.stream() .mapToInt(expenseEntity -> expenseEntity.getPrice() * getUserDiscount(expenseEntity)) .sum(); } public List<ExpenseTypeDictEntity> getExpenseTypes() { return expenses.stream() .flatMap(expenseEntity -> expenseEntity.getExpenseTypeDict().stream()) .collect(Collectors.toList()); } }
Такий клас легко покрити тестами, використовувати його буде UserService, в який достатньо вставити кілька моків — інтергаційний тест готовий!
Крім усього вище сказаного, слід зазначити, що навіть якщо дотримуватись усіх best practices, все одно, інколи не можна зробити рефакторинг через зовнішні обмеження. В першу чергу це база даних. Ми не можемо просто так переміщувати чи міняти поля в доменній моделі, бо до цього треба ще міняти стан самої бази, що може призвести до поганих наслідків.
Як вже говорилось, використання анемічної доменної моделі веде до унеможливлення або зниження ефективності використання багатьох технік і патернів.
На приклад Builder має чудове використання в доменному об’єкті. Можна легко перенести величезні методи ініціалізації об’єктів в самі об’єкти. В сервісі більше не буде методу
initExpenseEntity(expenseDTO)на 20 ліній з
setParameter(value)
... Можна додати методи які приймають DTO або інші об’єкти, ініціалізують поля значеннями за умовчанням і тд. Головне, що тепер цей код не буде знаходитись в сервісі.
Замість:
private void initExpenseEntity(ExpenseEntity expenseEntity, ExpenseDTO expenseDTO) { UserEntity userEntity = authenticationBO.getLoggedUser(); expenseEntity.setUser(userEntity); expenseEntity.setPrice(expenseDTO.getPrice()); expenseEntity.setComment(expenseDTO.getComment()); if (expenseDTO.getDate() != null) expenseEntity.setDate(expenseDTO.getDate()); else expenseEntity.setDate(LocalDateTime.now()); initExpenseTypes(expenseEntity, expenseDTO.getTypes()); ... }
Буде:
@Data @Entity @Table(name = "expense", schema = "expenses") public class ExpenseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "price") private Integer price; @Column(name = "comment") private String comment; @Column(name = "date") private LocalDateTime date; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private UserEntity user; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "expense_to_expense_type_dict", schema = "expenses", joinColumns = @JoinColumn(name = "expense_id"), inverseJoinColumns = @JoinColumn(name = "expense_type_id")) @Fetch(FetchMode.SUBSELECT) private List<ExpenseTypeDictEntity> expenseTypeDict; public static class Builder { private ExpenseEntity expense; public Builder() { expense = new ExpenseEntity(); } public Builder fromDTO(ExpenseDTO expenseDTO) { expense.price = expenseDTO.getPrice(); expense.comment = expenseDTO.getComment(); if (expenseDTO.getDate() != null) expense.date = expenseDTO.getDate(); else expense.date = LocalDateTime.now(); return this; } public Builder withUser(UserEntity user) { expense.user = user; return this; } public Builder withExpenseTypes(List<ExpenseTypeDictEntity> expenseTypes) { expense.expenseTypeDict = expenseTypes; return this; } public ExpenseEntity build() { return expense; } } }
Тоді сворення нового об’єкту в ExpenseService буде виглядати так:
@Override @Transactional public void addNewExpense(@Valid ExpenseDTO expenseDTO) { ExpenseEntity expenseEntity = createExpenseEntity(expenseDTO); expenseRepo.save(expenseEntity); } private ExpenseEntity createExpenseEntity(ExpenseDTO expenseDTO) { return new ExpenseEntity.Builder() .fromDTO(expenseDTO) .withUser(authenticationBO.getLoggedUser()) .withExpenseTypes(findExpenseTypes(expenseDTO.getTypes())) .build(); }
Стоп. Хіба це не буде порушенням патерну GRASP — Creator? Так, буде! Взагалі, ледь не половина патернів GoF в якійсь мірі щось порушують, згадати один тільки Visitor, який ненавидить ООП, але бувають ситуації, коли без того ніяк. Як ми вже переконались, не можливо написати Enterprise проект не порушивши жодного принципу, практики чи шаблону. Тому питання варто ставити інакше. Якщо ми щось порушуємо, які це принесе наслідки? Що це нам дасть? В випадку з Builder ми не ускладнюємо код. Даний шаблон тяжко зламати і легко використовувати. Є дуже зрозумілий для нових програмістів на проєкті. Тому його використання є більш ніж виправдане.
Так само стають недоступними багато технік рефакторингу. На приклад Replace method with Method Object (створюємо клас в конструктор якого передаємо this і викликаємо метод .compute()). В анемічній моделі проблема просто переноситься в інше місце, бо без сенсу передавати об’єкт сервісу.
В наших прикладах тяжко придумати якусь тяжку логіку, щоб використання цієї техніки було виправдане, але уявіть, що нам треба рахувати всі витрати даного юзера на підставі багатьох полів. В такому випадку наш метод не буде мати локальних змінних, а всі вони стануть полями нового класу, що може суттєво покращити чительність методу. Тоді буде легко розбити метод на багато дрібних, які не будуть передавати 4+ параметрів з метода в метод. Виглядатиме це так:
@Data @Entity @Table(name = "user", schema = "expenses") public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Integer id; @Column(name = "email") private String email; ... @OneToMany(mappedBy="user") private List<ExpenseEntity> expenses; ... public Integer calculateAllExpensesPrice() { return new ExpensesPriceCalculator(this).compute(); } } class ExpensesPriceCalculator { private List<ExpenseEntity> expenses; private Integer discount; public ExpensesPriceCalculator(UserEntity user) { // Copy relevant information from the expense ... this.expenses = user.getExpenses(); } public Integer compute() { // Perform long computation. ... initDiscount(); validateExpenses(); filterExpenses(); ... return calculateFinalPrice(); } }
Навіщо це все?
«Кожен дурень може написати код, зрозумілий компілятору, не кожен може написати його зрозумілим для людини». Знаю, що чули це вже багато разів. Але чи кожен розуміє навіщо це?
Для бізнесу, в першу дуже важливо наскільки швидко розробник може зробити новий функціонал. Для розробника головним чинником є якість існуючої кодової бази (не беручи до уваги досвідченість самого програміста). Якщо в нас зрозуміла архітектура і чистий код — процес розробки іде швидко, це заощаджує гроші замовнику і підвищує наш професійний рівень. Але якщо в нас Spaghetti code — часом може бути неможливо додати нову фічу без рефакторингу всього проєкту, що може зайняти тижні чи навіть місяці.
В кожного часто буває ситуація, коли треба швидко пофіксити якийсь баг. Ми розібрались що треба наклацати на фронті, викликаємо якийсь метод на сервері, вмикаємо дебаг та йдемо по довгих ланцюгах методів в сервісі, проходимо кілька методів на 20+ ліній і в кінці знаходимо проблему. Це займає багато часу, треба подивитись зразу багато коду, який нас не цікавить. Набагато краще розділити методи, і тримати логіку моделі в самій моделі, а доступ до неї в сервісах. Тоді в нас збільшиться кількість методів, що позитивно вплине на читабельність коду, так як кожен метод буде свого роду коментарем для коду який він робить.
Висновок
Rich Domain Model чудово звучить в теорії, дозволяє писати значно якісніший код з повнішим використанням ООП, але вимагає професійної команди, програмісти в якій добре знають стиль програмування один одного. Anemic Domain Model в свою чергу, є значно легшим в імплементіції і підтримці, але сильно урізає проєктувальні можливості і сприяє процедурному стилю програмування. Тому, в реальному світі, можна пробувати знаходити баланс і частину логіки переносити в доменну модель, а виклики залишати в сервісах.
Такий, «гібридний», підхід дає значну перевагу — ми можемо розділити логіку. Методи які будуть використовувати ініціалізовані поля об’єкту — помістимо в сам об’єкт (доменна модель), а логіку доступу до цієї моделі (мається на увазі діставання\запис в базу) — в сервіси. Таким чином, цей підхід робить зрозумілішим розділення моделі в шарі MVC на модель і сервіси, полегшує тестування, сприяє чистішому коду за рахунок логічнішого груповання методів.
Resources
Martin Fowler blog:
Book Refactoring — Martin Fowler.
dzone.com/...jects-finding-the-balance
www.javaworld.com/...esign-with-java-ee-6.html
56 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів