현재 진행하고 있는 토이 프로젝트에서 예매 기능을 개발하려고 하던 중,
해당 기능은 도메인 서비스에 로직이 들어가면 좋겠다고 판단되어 사고 흐름을 기록으로 남기고자 한다.
예매 기능을 수행하려면 어떤 동작들이 일어나야 할까? 대략적으로 다음과 같은 것들이 필요하다고 할 수 있겠다.
도메인 서비스란?에 대한 정의를 나름대로 DDD 책을 읽고 정리해 본 글이다.
예매 기능 플로우를 살펴보면 크게 세개의 애그리거트가 필요한 것을 알 수 있는데,
바로
이 때, 예매 응용서비스(application service)에서 예매 플로우를 모두 실행한다고 가정하자.
그렇게 된다면 예매 애그리거트가 아닌, 상영관 애그리거트의 로직(자리의 예약 가능성 검사), 유저 애그리거트의 로직(유저 존재 검사/활성화 계정인지 검사 등등)까지
예매 응용서비스에서 수행해야 한다.
이는 응용서비스의 책임에서 벗어난 행동으로써,(하나의 애그리거트에 속해있는 도메인 모델이 아닌, 다른 애그리거트의 도메인 모델까지 orchestration 해야함.)
응용서비스가 아닌, 별도의 도메인 서비스(domain service)에서 두 개 이상의 애그리거트가 필요한 로직을 수행하면 될 것이다.
DDD책을 읽을 때는 응용서비스/도메인서비스의 차이가 어느정도 명확하게 나뉘어 있어 보였고,
도메인 서비스의 정의와 예시가 잘 설명되어 있어 실제 구현도 크게 어렵지 않다고 생각했다.
하지만 막상 도메인 서비스를 처음 구현해 보려 하니, 아래와 같이 다양한 방식으로 구현이 될 수 있다고 생각되었다.
// 응용 서비스
class ReserveSeatService(
private val findCinemaUseCase: FindCinemaUseCase,
private val findUserUseCase: FindUserUseCase,
private val reserveSeatDomainService: ReserveSeatDomainService
): ReserveSeatUseCase {
override fun reserveSeat(cinemaId: Long, seatId: Long, userId: Long) {
// 모든 엔티티 조회(seat 제외) & 생성
val cinema = findCinemaUseCase.findCinemaById(cinemaId)
?: throw RuntimeException("No cinema")
val user = findUserUseCase.findUser(userId)
val reservation = Reservation(user, seat)
reserveSeatDomainService.reserveSeat(cinema, user, reservation)
}
}
// 도메인 서비스
class ReserveSeatDomainService(
private val reserveSeatPort: ReserveSeatPort
) {
fun reserveSeat(cinema: Cinema, seatId: Long, user: User, reservation: Reservation) {
// 도메인 로직만 포함
val seat = cinema.findSeat(seatId)
if (!seat.isAvailable()) {
throw RuntimeException("Seat is already reserved")
}
if(!user.isAvailable()) {
throw RuntimeException("User is not common status")
}
seat.reserved()
reserveSeatPort.save(reservation)
}
}
ReserveSeatService라는 응용 서비스의 주 관심사는 무엇일까?
‘좌석을 예약하는 것’이다.
예약을 위해서는
이 선행되어야 한다.
하지만 위의 case에서는 응용 서비스에서 먼저 예약 객체 생성을 하고, 도메인 서비스에서 선행 작업을 이후에 하고 있기 때문에 플로우상 적절치 않다는 생각이 들었다.
// 응용 서비스
class ReserveSeatService(
private val reserveSeatDomainService: ReserveSeatDomainService,
private val findCinemaUseCase: FindCinemaUseCase,
private val findUserUseCase: FindUserUseCase,
private val reserveSeatPort: ReserveSeatPort
): ReserveSeatUseCase {
override fun reserveSeat(cinemaId: Long, seatId: Long, userId: Long) {
// 조회만 담당
val cinema = findCinemaUseCase.findCinemaById(cinemaId)
?: throw RuntimeException("No cinema")
val user = findUserUseCase.findUser(userId)
?: throw RuntimeException("No user")
// 실제 예약 객체 생성은 도메인 서비스에 위임
// (예약 객체를 생성하기 전에 비즈니스 로직이 실행되어야 하므로.)
val reservation = reserveSeatDomainService.makeReservation(cinema, seatId, user)
reserveSeatPort.save(reservation)
}
}
// 도메인 서비스
class ReserveSeatDomainService {
fun makeReservation(cinema: Cinema, seatId: Long, user: User): Reservation {
val seat = cinema.findSeat(seatId)
if (!seat.isAvailable()) {
throw RuntimeException("Seat is already reserved")
}
if(!user.isAvailable()) {
throw RuntimeException("User is not common status")
}
seat.reserved()
// 도메인 서비스에서 예약 객체 생성. 영속화는 응용 서비스에서 담당
// 따라서, 도메인 서비스에서는 '순수 비즈니스 로직'만 담당
val reservation = Reservation(user, seat)
return reservation
}
}
응용 서비스에서는 도메인 서비스에서 비즈니스 로직을 실행하는 데에 필요한 애그리거트(cinema, user)를 조회해와, seatId와 함께 도메인 서비스에 전달한다.
이후 도메인 서비스에서는 어떠한 의존성도 없이(port, usecase같은 간접적인 spring 관련 의존성 또한 없이)
순수한 도메인 로직만을 수행하고 있다.
이렇게 되면
⭐ 순수 비즈니스 로직만을 테스트 할 수 있는 용이점(testablility)이 훨씬 높아져서 좋을 것이라고 생각된다.
// 응용 서비스
class ReserveSeatService(
private val reserveSeatDomainService: ReserveSeatDomainService
): ReserveSeatUseCase {
override fun reserveSeat(cinemaId: Long, seatId: Long, userId: Long) {
reserveSeatDomainService.makeReservation(cinemaId, seatId, userId)
}
}
// 도메인 서비스
class ReserveSeatDomainService(
private val reserveSeatPort: ReserveSeatPort,
private val findCinemaUseCase: FindCinemaUseCase,
private val findUserUseCase: FindUserUseCase,
) {
fun makeReservation(cinemaId: Long, seatId: Long, userId: Long): Reservation {
// 조회
val cinema = findCinemaUseCase.findCinemaById(cinemaId)
?: throw java.lang.RuntimeException("No cinema")
val user = findUserUseCase.findUser(userId)
?: throw java.lang.RuntimeException("No user")
// 비즈니스 로직
val seat = cinema.findSeat(seatId)
if (!seat.isAvailable()) {
throw RuntimeException("Seat is already reserved")
}
if(!user.isAvailable()) {
throw RuntimeException("User is not common status")
}
seat.reserved()
// 객체 생성 & 영속화
val reservation = Reservation(user, seat)
reserveSeatPort.save(reservation)
return reservation
}
}
응용 서비스는 이전과 다르게 굉장히 축소되었지만,
반대로 도메인 서비스는 비대해졌다.
따라서, 나는 응용 서비스의 책임인 객체 간의 흐름만을 제어할 수 있도록, 단순 객체 조회정도 + 도메인 서비스에 전달만을 할 것이며(Case 2에 해당)
도메인 서비스에서는 전달받은 애그리거트 객체들을 사용하여 실제 비즈니스 로직을 수행하도록 할 것이다.
추가적으로, 도메인 서비스에서는 (웬만하면) 의존성 없이 순수한 서비스 하나만을 유지하도록 노력해 볼 것이다.
(물론 이는 개발하면서 생각이 바뀔 수 있는 부분이라고 생각한다.)