도메인 객체 중심 아키텍처 란, 도메인 모델을 반영하는 객체를 만들어두고, 그것을 중심으로 개발하는 아키텍처를 의미한다.
public class Category { private int id; private String name; private List<Product> productList; } public class Product { private int id; private String name; private int price; private Category category; }
이렇게 객체를 만들어두고 그 안에 정보를 담아서 각 계층 사이에 전달 하게 만드는 것이 도메인 객체 중심 아키텍처이다.
도메인 객체도 결국 자바 객체이기 때문에 속성과 행위를 가지고 있으며, 도메인들끼리 메시지를 주고받기 때문에 비즈니스 로직에 대한 구현이 명확해진다.
또한 도메인 모델은 DB 엔티티 설계에도 영향이 반영되기 때문에, DB의 엔티티와 유사하다.
→ 결국 안정적인 도메인 모델을 기반으로 시스템의 기능을 구현할 경우, 시스템의 기능이 변경되더라도 비즈니스의 핵심 정책이나 규칙이 변경되지 않는 한 전체적인 구조가 한 번에 흔들리지 않는다.
서비스 계층은 단순히 요청을 받아 도메인 객체에 이를 위임 해주는 공간이다.
서비스 레이어는 다양한 도메인들을 읽어 제공하는데, 만약 비즈니스 로직이 모두 서비스에서 개발된다면
때문에 도메인 객체에 대한 비즈니스 로직이 있다고 하면 서비스 계층에 작성하는 것 보다, 해당 도메인에 작성하는 것이 좋다.
대신 여러 종류의 도메인 객체를 조합해야 하거나, DAO 계층과 연동되어야 하는 로직 등은 서비스 계층에 작성하는 것이 좋다.
객체지향스러운 개발을 할 수 있다.
→ 도메인들이 각 객체로서 속성과 행위를 지니며 메세지를 주고 받을 수 있다.
도메인 객체의 응집도를 높일 수 있다.
→ 객체와 비즈니스 로직을 파악하기 위해 전체 서비스 레이어를 살펴보는 것이 아닌, 해당 도메인 객체와 연관된 비즈니스 로직을 파악하기 위해서는 해당 객체만 확인하면 된다.
DI를 줄일 수 있다.
→ 해당 비즈니스 로직을 갖는 서비스 계층을 DI 해야 하는 경우를 줄일 수 있다.
서비스 계층의 코드가 간결하다.
→ 도메인 객체가 스스로 처리가능한 비즈니스 로직을 갖고 있으므로 서비스 계층의 코드를 간결하게 유지하며 유지보수성이 높아진다.
테스트 코드 작성이 용이하다.
Service의 비즈니스 로직을 도메인으로 옮기는 과정
✔︎ 한번에 많은 부분을 고치려 하지 말고 나눠서 부분부분 리팩터링하기
✔︎ 전체 기능은 인수 테스트로 보호한 뒤 세부 기능을 TDD로 리팩터링하기
- Domain으로 옮길 로직 찾기
스프링 빈을 사용하는 객체와 의존하는 로직을 제외하고는 도메인으로 옮기기
- Domain의 단위 테스트 작성
서비스 레이어에서 옮겨 올 로직의 기능을 테스트
추가적인 Test 클래스가 생성될 수 있음
- TDD 에 맞게 로직 옮기기
기존 로직을 지우지 말고 새로운 로직을 만들어 수행
정상 동작 확인 후 기존 로직 제거
위의 코드의 경우 만약 다른 서비스에서 throw new CannotDeleteException("질문을 삭제할 권한이 없습니다.");
를 해야할 경우 똑같은 로직이 중복될 수 있다. 또한 만약에 에러 메시지를 수정하게 되면 도메인에서 찾아서 한번에 변경하는 것이 아니라, 로직들을 일일히 찾아서 변경해야한다. 이는 실수를 불러일으키게 된다.
하지만 아래와 같이 코드를 변경하게 되면 테스트 코드 작성과 코드를 수정하기에도 용이해지며 재사용성이 높아지게 된다.
비즈니스 로직을 모아둔 Service Layer
@Transactional public void deleteQuestion(User loginUser, Long questionId) throws CannotDeleteException { Question question = findQuestionById(questionId); if (!question.isOwner(loginUser)) { throw new CannotDeleteException("질문을 삭제할 권한이 없습니다."); } List<Answer> answers = question.getAnswers(); for (Answer answer : answers) { if (!answer.isOwner(loginUser)) { throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); } } List<DeleteHistory> deleteHistories = new ArrayList<>(); question.setDeleted(true); deleteHistories.add(new DeleteHistory(ContentType.QUESTION, questionId, question.getWriterId(), LocalDateTime.now())); for (Answer answer : answers) { answer.setDeleted(true); deleteHistories.add(new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriterId(), LocalDateTime.now())); } deleteHistoryService.saveAll(deleteHistories); }
비즈니스 로직을 도메인 가까이 리팩터링한 이후 Service Layer
@Transactional public void deleteQuestion(User loginUser, Long questionId) throws Exception { List<DeleteHistory> deleteHistories = new ArrayList<>(); Question question = findQuestionById(questionId); question.delete(loginUser, deleteHistories); deleteHistoryService.saveAll(deleteHistories); }
@Entity @Where(clause = "deleted = 0") @SQLDelete(sql = "update question set deleted = 1 where id = ?") public class Question extends AbstractDate { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; ... public void delete(User loginUser, List<DeleteHistory> deleteHistories) throws Exception { validateDelete(loginUser); answers.validateDelete(loginUser); setDeleted(true); deleteHistories.add(new DeleteHistory(ContentType.QUESTION, id, getWriter(), LocalDateTime.now())); answers.deleteAll(loginUser, deleteHistories); } public void validateDelete(User loginUser) throws CannotDeleteException { if (!isOwner(loginUser)) { throw new CannotDeleteException("질문을 삭제할 권한이 없습니다."); } }
관련해서 진행하였던 미션
서비스 로직을 도메인 중심으로 리팩터링
[참조]
https://mangkyu.tistory.com/160
https://zorba91.tistory.com/239
https://nesoy.github.io/articles/2018-04/why-close-to-domain