https://www.amazon.com/Domain-Driven-Design-Golang-maintainable-business/dp/1804613452
기본적인 몇 가지 DDD core concept에 대해서 알아보도록 하자.
이직을 해온 회사의 팀이 subscription와 payment를 담당하는 팀이라고 하자. 해당 팀에서는 다음의 코드를 가지고 있다.
package main
import "context"
type UserType = int
type SubscriptionType = int
const (
unknownUserType UserType = iota
lead
customer
churned
lostLead
)
const (
unknownSubscriptionType SubscriptionType = iota
basic
premium
exlusive
)
type UserAddRequest struct {
UserType UserType
Email string
SubType SubscriptionType
PaymentDetails PaymentDetails
}
type UserModifyRequest struct {
ID string
UserType UserType
Email string
SubType SubscriptionType
PaymentDetails PaymentDetails
}
type User struct {
ID string
PaymentDetails PaymentDetails
}
type PaymentDetails struct {
stripeTokenID string
}
type UserManager interface {
AddUser(ctx context.Context, request UserAddRequest) (User, error)
ModifyUser(ctx context.Context, request UserModifyRequest) (User, error)
}
DDD에 대해 더 자세히 알아보면서, 다시 위 코드를 수정해보도록 하자.
위 코드는 subscription과 payment에 관한 business logic을 해결하는 것이 중점이다. 따라서 domain은 subscription과 payment가 된다. Evans는 domain이란 a sphere of knowledge, influence, or activity - Addison-Wesley Professional라고 부른다. 즉, domain는 지식, 영향, 활동의 영역을 말하며 어떠한 로직을 통해 영향을 미치는 것을 의미한다. domain 중심으로 language와 system을 모델링해야한다는 것이다.
sub-domain은 추상적인 상위 domain의 하위 개념으로, domain과 sub-domain은 서로 호환이 가능하다. 가령, subscription과 payment은 더 큰 busniness logic의 하위 domain이므로 sub-domain이라고 할 수 있다. 즉 전체 제품의 관점에서는 sub-domain인 것이다. 단, 우리가 다루는 팀에서는 이 문제를 더욱 세분화하였고, 세분화한 business logic(problem)이 subscription과 payment에 관한 것이다. 따라서, 세분화된 팀의 역할에 따라 전체의 sub-domain이 주된 domain이 될 수 있는 것이다.
Ubiquitous language는 domain expert와 technical experts간의 언어 교집합이다.
[사진1]
각 부서마다, 팀마다 사용하는 언어가 다르다. 이는 서로 다른 단어를 사용하고 있지만 같은 의미를 내포할 수 있으며, 서로 같은 단어를 사용하고 있지만 다른 의미를 내포할 수 있다는 것이다. 가령, engineer팀이 사용하고 있는 customer
라는 단어와 marketing team에서 사용하는 단어는 서로 다를 수 있다.
이런 일을 대비해서 서로 다른 팀 또는 domain expert와 technical experts간의 언어적 교집합을 만들고, 서로 공유할 필요가 있다. 시간이 지남에 따라 의미가 변하므로, 이를 정의하는 위키를 만들어 계속 업데이트해주는 것이 좋다. 이것이 바로 Ubiquitous language이다. Ubiquitous language을 통해서 서로 같은 내용에 대해서 시스템을 디자인하고 요구사항에 대해 토의 할 수 있으며, 심지어는 소스코드에도 사용할 수 있다.
IT project에서 발생하는 실패 중 하나는 요구사항이 전달 과정에서 누락되는 경우이다. 가령 business team에서 customer가 여러 개의 accounts를 지원한다는 용어를 사용한다고 하자. 그러나 engineer팀에서는 그 이야기를 듣고 토의를 하다보니 customer
라는 용어가 시스템에서 사용하지 않을 수 있다. 오히려 user
라는 용어를 사용하여 한 계정 당 하나의 user
가 있다고 생각하는 경우가 있다. 이러한, 경우 사소한 문제가 전체 시스템에 큰 재앙을 불러와 줄 수 있다. 이는 engineer가 business용어와 business팀 간의 ubiquitous language에 집중하지 않아서 발생한 문제이다.
여기서, customer
가 user
로 변이된 것에 집중해보도록 하자. user
는 customer
의 다른 표현일 뿐이다. 그러나, 이는 엄청난 차이를 가져다 준다.
type UserType = int
type SubscriptionType = int
const (
unknownUserType UserType = iota
lead
customer
churned
lostLead
)
const (
unknownSubscriptionType SubscriptionType = iota
basic
premium
exlusive
)
subscription
에 대해서는 이미 이전에 domain expert와 주고받은 내용이므로, 문제가 되지 않는다. 문제는 UserType
이라는 부분이다.
type UserAddRequest struct {
UserType UserType
Email string
SubType SubscriptionType
PaymentDetails PaymentDetails
}
type UserModifyRequest struct {
ID string
UserType UserType
Email string
SubType SubscriptionType
PaymentDetails PaymentDetails
}
type User struct {
ID string
PaymentDetails PaymentDetails
}
type PaymentDetails struct {
stripeTokenID string
}
type UserManager interface {
AddUser(ctx context.Context, request UserAddRequest) (User, error)
ModifyUser(ctx context.Context, request UserModifyRequest) (User, error)
}
domain expert와 토의한 결과 app을 사용하는 사용자들을 user
라고 하고, lead
, customer
, churned
, lostLead
라는 4가지 state를 갖는다고 하자. 여기까지는 문제가 없지만, 문제는 AddUser
, ModifyUser
함수가 우리의 domain에서는 없는 concept으로, doamin expert는 이를 이해하지 못할 수 있다. 즉, 사전에 이야기한 domain이 아니라는 것이다. 가령, user
가 lead
상태에서 subscription을 완료하면 customer
가 된다고 하자. lead
는 AddUser
로 user
를 추가할 때 상태를 변경해줄 수 있다. 문제는 lead
가 customer
상태가 되어야 하는데 Convert
도 아니라, ModifyUser
를 써야하는 것이다. 이는 lead
, customer
라는 세세한 상태의 domain을 사용할 이유가 사라졌을 뿐더러, user
라는 도메인으로 lead
와 customer
를 두루뭉술하게 표현한 것과 같다. customer
가 되는 것은 lead
라는 단계를 거칠 필요가 없고, AddUser
, ModifyUser
함수를 거치기만 하면 되기 때문이다.
처음으로 다시 돌아가서, lead
상태에서 subscription
하면 customer
가 된다고 했다. 따라서 lead
에 대한 도메인과 customer
에 대한 도메인을 만들고 lead
에서 customer
로 변경되도록 하자.
package main
import (
"context"
)
type SubscriptionType = int
const (
unknownSubscriptionType SubscriptionType = iota
basic
premium
exlusive
)
type LeadRequest struct {
email string
}
type Lead struct {
id string
}
type LeadCreator interface {
CreateLead(ctx context.Context, request LeadRequest) (Lead, error)
}
type Customer struct {
leadID string
userID string
}
func (c *Customer) UserID() string {
return c.userID
}
func (c *Customer) SetUserID(userID string) {
c.userID = userID
}
type LeadConvertor interface {
Convert(ctx context.Context, subSelection SubscriptionType) (Customer, error)
}
func (l Lead) Convert(ctx context.Context, subSelection SubscriptionType) (Customer, error) {
panic("implement me")
}
다음과 같이 변경하면 더욱 실세계의 모델을 잘 반영하고, 우리가 앞서 정의한 domain과 일치하는 모습을 보여준다. user
라는 두루뭉술한 domain을 세분화하여 lead
, customer
로 domain을 만들고 lead
가 customer
로 변환되도록 하는 것을 보여준다.
주의할 것이 있는데, 이러한 domain은 항상 bounded context안에서만 의미가 있다는 것이다. 이는 우리가 일하는 회사, business team에 관련된 것으로 ubiquitous language가 bounded context안에서 동작하기 때문이다. 만약, 다른 회사나 다른 business team에 가게되면 해당 용어는 힘을 잃고 새로운 용어를 배워야하는 것이 맞다.
마켓팅팀이 말하는 campaign과 subscription team에서 말하는 campaign은 같은 단어를 써도 다른 의미를 내포할 수 있다.
[사진 2.3]
campaign과 customer는 마켓팅팀, subscription팀이 같은 단어를 쓰고 있지만, 서로 다른 의미를 말하고 있음을 보여준다. 이처럼 domain이란 bounded context안에서 의미가 있는 것이지, 그 밖에서는 다른 의미를 가질 수 있다. 즉, 우리는 domain을 정의하면서 큰 의미를 더 작게 세분화해서 bounded contexts에 정의하는 것이기 때문에, 각 context에 따라 같은 단어라도 다른 의미를 가질 수 밖에 없는 것이다.
bounded contexts끼리 통신할 일이 있는데, 이 때 bounded contexts끼리의 relationship을 만들어주면 된다. 가령, 마켓팅팀의 customer를 가져와, subscription team의 customer를 변환해주는 관계가 있으면 된다. 이 relationship을 만드는 몇 가지 모델링 기법들이 존재한다.
1. Open Host Service
2. Published language
3. Anti-corruption layer
Open Host Service 패턴들에 대해서만 알아보도록 하자.
Open Host Service는 다른 시스템(또는 하위 시스템)에게 우리의 시스템으로 접근할 수 있도록 해주는 수단을 말한다. 전형적으로 Open Host Service는 RPC, RESTful, gRPC, XAM API 등이 있다.
[사진 2.4]
Open Host Service를 통해 서로 다른 Bounded Context들끼리 통신하는 것을 볼 수 있다.
우리의 payment와 subscription을 마켓팅팀의 bounded context에 제공하기 위해서 endpoint를 열어 우리의 context와 함께 user의 정보를 함께 전달해주면 된다. 이것이 서로 다른 bounded context끼리의 domain차이를 극복하기 위한 relationship인 것이다.
type UserHandler interface {
IsUserSubscriptionActive(ctx context.Context, userID string) bool
}
type UserActiveResponse struct {
IsActive bool
}
func router(u UserHandler) {
m := mux.NewRouter()
m.HandleFunc("/user/{userID}/subscription/active", func(w http.ResponseWriter, r *http.Request) {
uID := mux.Vars(r)["userID"]
if uID == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
isActive := u.IsUserSubscriptionActive(r.Context(), uID)
b, err := json.Marshal(UserActiveResponse{IsActive: isActive})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = w.Write(b)
}).Methods(http.MethodGet)
}
다음의 코드는 /user/{userID}/subscription/active
endpoint를 열어 다른 팀에서 해당 유저가 subscription이 active인지 아닌지 확인하도록 한다.
Published language
는 외부에 우리의 bounded context를 노출할 때 외부 팀과 협의하는 하나의 language이다. 즉, ubiquitous language가 우리의 팀 내부에서 정의한 언어라면, Published language
는 이 ubiquitous language를 Open Host Service로 외부에 노출시킬 때, bounded context를 더욱 명확하게 만드는 설명하는 방법이다. 가장 대표적으로 OpenAPI
또는 gRPC
가 있다.
Anti-corruption layer
는 adapter layer
로 anti-corruption layer
라고도 한다. 이는 다른 system으로부터 model들을 변형시켜서 전달하는 방법인데, 가령 Open Host Service에서 외부로 모델을 전달할 때, 외부 bounded context에 맞게 변형해서 전달해주는 방법이다. 또는, adapter layer라는 application을 놓아서, 서로 다른 bounded context간의 차이를 변환해주는 것이다.
[사진 2.10]