factory pattern은 OOP에서 나온 개념으로 다른 object들을 생성하는 주요한 책임을 가진 object를 말한다.
golang에서 factory pattern을 적용하여 만든다면, 다음과 같이 만들 수 있다.
type Car interface {
BeepBeep()
}
type BMW struct {
heatedSeatSubscriptionEnabled bool
}
func (B BMW) BeepBeep() {
//TODO implement me
panic("implement me")
}
type Tesla struct {
autoPilotEnabled bool
}
func (t Tesla) BeepBeep() {
//TODO implement me
panic("implement me")
}
func BuildCar(carType string) (Car, error) {
switch carType {
case "bmw":
return BMW{heatedSeatSubscriptionEnabled: true}, nil
case "tesla":
return Tesla{autoPilotEnabled: true}, nil
default:
return nil, errors.New("unknown car type")
}
}
func main() {
myCar, err := BuildCar("tesla")
if err != nil {
log.Fatal(err)
}
myCar.BeepBeep()
}
Car
라는 인터페이스를 만들고, 이는 BeepBeep
이라는 메서드를 구현하도록 한다. 차 종류로는 BMW
, Tesla
가 있고, 이들을 생성하기 위해서는 BuildCar
라는 factory method를 이용해야한다. 비록 factory pattern이 다른 object들을 만드는 object라고해도, golang에서는 OOP의 개념이 완전히 적용되는 것은 아니기에 factory method로 대체할 수 있다.
BuildCar
가 차 종류에 대한 string을 받고 Car
인터페이스를 반환하는데, 사용자 입장에서는 어떤 차든지 간에 behavior가 중요하지, 그 타입 자체는 중요하지 않기 때문에 문제가 되지 않는다.
factory
는 복잡한 구조체의 생성을 표준화해주어, 상대적으로 간단하게 객체를 생성해준다. factory는 객체의 여러 attributes, methods 등 내부의 detail한 부분들을 하나로 묶어주어 생성해주며, information hiding 기능도 제공해, 외부의 유저가 object를 맘대로 변경하는 것을 막을 수 있다. 이는 business invariants(비지니스 상수)를 object 생성 시간에 강제할 수 있어 매우 큰 장점을 가지는데, 이 덕분에 우리의 domain model을 매우 simple하게 만들어줄 수 있다.
가령, 미용실의 예약시스템을 만드는데 이용 시간 외에 다른 시간에 예약을 한다고하면 다음과 같이 처리할 수 있다.
type Booking struct {
id uuid.UUID
from time.Time
to time.Time
hairDresserID uuid.UUID
}
func CreateBooking(from, to time.Time, hairDresserID uuid.UUID) (*Booking, error) {
closingTime, _ := time.Parse(time.Kitchen, "17:00pm")
if from.After(closingTime) {
return nil, errors.New("no appointments after closing time")
}
return &Booking{
hairDresserID: uuid.New(),
id: uuid.New(),
from: from,
to: to,
}, nil
}
CreateBooking
은 factory로 Booking
구조체를 만들어준다. 단, 현재 시간(closingTime
)이 from
보다 이후이면 error를 발생시켜준다. 이는 business invariants인 '운영 시간 이외에는 예약이 불가능하다'를 factory에서 보장해주고 있는 것이다.
repositories는 data sources(s3, disk file, database 등등)에 접근하기 위해 필료한 로직들을 담고 있는 코드이다.
repository layer를 사용하면, data source에 접근하는 코드들이 추상화되기 때문에, 실제 구현부와 decoupling할 수 있다는 장점이 있다. 이는 실제 구현부에서 사용하는 database가 달라져도 repository를 사용하는 코드는 변동이 없고, repositroy의 구현부만 변경하면 된다는 것이다. 즉, repository 내부에서만 변동이 생긴다는 것이다.
repository를 만들면서 실수하는 대부분은 하나의 repository 구조체에 모든 database 접근 정보를 다담으려고 하는 것이다. 즉, database 당 하나의 repository를 둔 경우이다. 이렇게 말고, aggregate 당 repositroy 구조체 하나로 만드는 것이 좋다. 다음의 예제를 보자.
[그림 4-1]
위 그림을 보면 booking database의 Booking table
, Hairedressers table
, Customers bookings table
, Customers table
이 있지만, 정적 repository는 Booking repository
와 Customer repository
밖에 없는 것을 알 수 있다. 즉, database table은 repository layer와 다르다는 것을 알려준다. 또한, repository layer는 우리의 domain layer와 다르고, decoupling되어있는 것 또한 확인할 수 있다.
이제 위의 설계를 코드로 옮겨보도록 하자. BookingRepository
인터페이스를 다음과 같이 만들어볼 수 있다.
type BookingRepository interface {
SaveBooking(ctx context.Context, booking Booking) error
DeleteBooking(ctx context.Context, booking Booking) error
}
BookingRepository
는 Booking
을 저장하고 삭제하는 기능을 가지며, domain model은 BookingRepository
을 사용하여 Booking
을 관리한다. Booking
package에 위의 코드가 저장되고, fatory
와 추후에 배울 service
역시도 같은 Booking
패키지에 저장된다.
이제 Postgres
database를 사용하는 BookingRepository
구현체를 만들어보도록 하자. BookingRepository
구현체는 직접 Postgres
database와 interfaction을 가지는 것이 특징이다.
func NewPostgresRepository(ctx context.Context, dbConnString string) (*PostgresRepository, error) {
conn, err := pgx.Connect(ctx, dbConnString)
if err != nil {
return nil, fmt.Errorf("failed to connect to db: %w", err)
}
defer conn.Close(ctx)
return &PostgresRepository{connPool: conn}, nil
}
func (p PostgresRepository) SaveBooking(ctx context.Context, booking Booking) error {
_, err := p.connPool.Exec(
ctx,
"INSERT into bookings (id, from, to, hair_dresser_id) VALUES ($1,$2,$3,$4)",
booking.id.String(),
booking.from.String(),
booking.to.String(),
booking.hairDresserID.String(),
)
if err != nil {
return fmt.Errorf("failed to SaveBooking: %w", err)
}
return nil
}
func (p PostgresRepository) DeleteBooking(ctx context.Context, booking Booking) error {
_, err := p.connPool.Exec(
ctx,
"DELETE from bookings WHERE id = $1",
booking.id,
)
if err != nil {
return fmt.Errorf("failed to DeleteBooking: %w", err)
}
return nil
}
Postgres
database를 사용하는 위 로직은 매우 단순하며 어떠한 domain logic이 없다. 이 구현체를 가진 BookingRepository
interface가 application service layer에서 사용되는 것이다. 다음으로 service에 대해서 알아보도록 하자.
DDD에서는 우리의 코드를 조직화하기 위해서 다양한 service들을 사용할 수 있다. application services
, domain services
, infrastructure services
가 있다. 이 3가지 service에 대해서 알아보고, 언제 이들이 유용하지 알아보도록 하자.
Domain services
는 특정 activity를 완료하는 domain내 stateletss 연산이다. 때때로, 우리는 entity나 value object 내에서 처리하기 까다로운 일들을 마주한다. 이 때에 domain service
를 사용하면 된다.
domain service
를 언제 사용할 지 결정하는 것은 매우 까다로운 일이지만, 다음의 경우들이 있다.
1. 하나의 domain안에서 상당 수의 business logic을 수행해야하는 코드를 만들어야 할 때
2. 특정 domain object를 다른 domain object로 변경할 때
3. 두 개 이상의 domain object를 받아서 value를 계산할 때
다음의 예시 코드를 보도록 하자.
type Product struct {
ID int
InStock bool
InSomeonesCart bool
}
func (p *Product) CanBeBought() bool {
return p.InStock && !p.InSomeonesCart
}
type ShoppingCart struct {
ID int
Products []Product
IsFull bool
MaxCartSize int
}
func (s *ShoppingCart) AddToCart(p Product) bool {
if s.IsFull {
return false
}
if p.CanBeBought() {
s.Products = append(s.Products, p)
return true
}
if s.MaxCartSize == len(s.Products) {
s.IsFull = true
}
return true
}
언뜻보면 문제가 없어보이지만, AddToCart
부분에 문제가 있다. AddToCart
는 ShoppingCart
entity가 직접적으로 Product
라는 entity에 접근한다. entity를 직접적으로 interaction하지 않아야하는 이유는 서로의 bounded context가 겹쳐 분리가 모호해질 수 있기 때문인데, 즉 하나의 entity안의 비지니스 로직 안에 또 다른 entity의 비지니스 로직이 실행될 수 있기 때문이다. 그렇기 때문에 Product
를 직접적으로 참조하기 보다는 Product ID
와 같은 식별자를 참조하는 것이 좋은 것이다.
위 코드에서 AddToCart
안에 Product
는 CanBeBought
라는 비지니스 로직을 실행한다. 또한, AddToCart
가 진짜로 ShoppingCart
의 business logic이냐고한다면, 굉장히 모호하다. ShoppoingCart
에 대한 설정도 아니라, 물건을 담는 행위
를 ShoppingCart
에서 담당하기 보다는 aggregator
나 service
에서 하는 것이 적절해보이는 것이다. 즉, entity의 scope를 벗어난 business logic이라는 것이다.
따라서, domain service
는 이렇게 다른 entity와의 interaction이 있거나 entity domain 자체에서는 실행하기 애매한 business logic을 가지고 온다. 즉, entity의 비지니스 로직이 다른 entity의 실행을 요구하거나, 비지니스 로직이 entity의 범위 밖의 일이라면 domain service
로 들고오는 것이다.
type CheckoutService struct {
shoppingCart *ShoppingCart
}
func NewCheckoutService(shoppingCart *ShoppingCart) *CheckoutService {
return &CheckoutService{shoppingCart: shoppingCart}
}
func (c CheckoutService) AddProductToBasket(p *Product) error {
if c.shoppingCart.IsFull {
return errors.New("cannot add to cart, its full")
}
if p.CanBeBought() {
c.shoppingCart.Products = append(c.shoppingCart.Products, *p)
return nil
}
if c.shoppingCart.MaxCartSize == len(c.shoppingCart.Products) {
c.shoppingCart.IsFull = true
}
return nil
}
이제 두 entity들을 가지고 비지니스 로직을 처리하는 domain service를 만들었다. 이 domain service가 있으므로, 추가적으로 entity를 더 넣을 수도 있다. 가령 discount entity나 shipping entity들도 넣어서 cart에 넣을 때 할인율이나 추가금액을 설정할 수 있는 것이다. 이렇게 하나의 domain service에 비지니스 로직을 구현한 것은 business invariants가 자동적으로 설정되도록 하고, client가 이 domain service를 사용해야만 하도록 다른 behavior을 구현할 수 있도록 강제한다.
domain service는 이렇게 stateless domain logic을 구성할 필요가 있을 때 사용하면 된다. 단, 그렇지 않다면 application service를 사용해야한다.
Application services는 다른 services와 repositories를 구성할 때 사용되고, 이들은 여러 모델 간의 transactional한 보장을 관리하는 책임을 갖는다. 즉, 실제 client가 실행하는 command와 관련된 service로, 어떠한 domain business logic(domain service나 domain object가 가진다.)을 갖진 않지만 application이 반드시 필요한 여러 단계를 수행해준다.
좀 더 쉽게 말하자먼, Application services는 다른 domain object가 있는 application layer에서 다른 layer와 접근하기 위한 connecter이자, coordinater이다. 일반적으로 entity나 value, aggregates와 같은 domain object에 기술적인 모듈을 넣지 않는다. 가령 domain에 관해서 database에 접근하고, network로 통신하고, MQ로 메시지를 관리하는 등의 일은 Application services에서 담당하는 것이다. 즉, domain과 관련된 business logic 중에 technology와 관련된 일은 domain object 자체에 넣는 것이 아니라, Application services에서 처리하도록 하는 것이다.
다음의 예시를 보도록 하자.
type accountKey = int
const accountCtxKey = accountKey(1)
type BookingDomainService interface {
CreateBooking(ctx context.Context, booking Booking) error
}
type BookingAppService struct {
bookingRepo BookingRepository
bookingDomainService BookingDomainService
}
func NewBookingAppService(bookingRepo BookingRepository, bookingDomainService BookingDomainService) *BookingAppService {
return &BookingAppService{bookingRepo: bookingRepo, bookingDomainService: bookingDomainService}
}
func (b *BookingAppService) CreateBooking(ctx context.Context, booking Booking) error {
u, ok := ctx.Value(accountCtxKey).(*customer.Customer)
if !ok {
return errors.New("invalid customer")
}
if u.UserID() != booking.userID.String() {
return errors.New("cannot create booking for other users")
}
if err := b.bookingDomainService.CreateBooking(ctx, booking); err != nil {
return fmt.Errorf("could not create booking: %w", err)
}
if err := b.bookingRepo.SaveBooking(ctx, booking); err != nil {
return fmt.Errorf("could not save booking: %w", err)
}
return nil
}
BookingAppService
는 application service로 BookingDomainService
와 BookinRepository
로 구성된다. BookinRepository
을 이용하여 BookingDomainService
로 만들어진 Booking
을 저장하도록 transactional 연산을 수행한다. 이 처럼 다른 technology, 즉 다른 layer(repository, bookingDomainService)와 domain object(Booking)가 interaction을 가져야 하는 상황에서 Application service
를 사용하는 것이다.
application service가 가장 많이 사용되는 use case는 User Interface(UI)
이다. UI는 여러 개의 domain service들로 이루어져 있다. 다음의 그림을 보도록 하자.
[그림4-2]
UI는 하나의 application service에 여러 개의 domain service를 담아 처리할 수 있다.
예제로 요즘 web application들은 다음을 따른다.
1. payment를 받아들이고
2. 결제 정보를 email로 전달하고
3. user behavior를 분석
우리는 위의 코드에 email
을 전달하는 기능을 추가하도록 하자. email
을 전달하는 기능은 외부 technology를 추가함으로 application service
처럼 보이지만, 엄연히 따지면 Infrestructure service
이다. Infrestructure service
는 외부의 모듈에 접근하는 layer인데, 반드시 필요하진 않은 경우에 사용한다. 다음의 물음에 대답해보자.
이 나머지는 infrastructure service로 생각해도되는데, infrastructure service는 없어도 domain의 business logic이나 application 구동에 큰 영향을 미치지 않는다. 부가적인 기능으로 주로 logging이나 email서비스 등이 있다.
infrastructure service는 application service와 domain service에서 자유롭게 사용할 수 있다는 특징을 갖는다.
이제 email을 전송하는 infrastructure service를 만들어보자.
type EmailSender interface {
SendEmail(ctx context.Context, to string, title string, body string) error
}
const emailURL = "https://mandrillapp.com/api/1.0/messages/send\""
type MailChimp struct {
apiKey string
from string
httpClient http.Client
}
type MailChimpReqBody struct {
Key string `json:"key"`
Message struct {
FromEmail string `json:"from_email"`
Subject string `json:"subject"`
Text string `json:"text"`
To []struct {
Email string `json:"email"`
Type string `json:"type"`
} `json:"to"`
} `json:"message"`
}
func NewMailChimp(apiKey string, from string, httpClient http.Client) *MailChimp {
return &MailChimp{apiKey: apiKey, from: from, httpClient: httpClient}
}
func (m MailChimp) SendEmail(ctx context.Context, to string, title string, body string) error {
bod := MailChimpReqBody{
Key: m.apiKey,
Message: struct {
FromEmail string `json:"from_email"`
Subject string `json:"subject"`
Text string `json:"text"`
To []struct {
Email string `json:"email"`
Type string `json:"type"`
} `json:"to"`
}{
FromEmail: m.from,
Subject: title,
Text: body,
To: []struct {
Email string `json:"email"`
Type string `json:"type"`
}{{Email: to, Type: "to"}},
},
}
b, err := json.Marshal(bod)
if err != nil {
return fmt.Errorf("failed to marshall body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, emailURL, bytes.NewReader(b))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if _, err := m.httpClient.Do(req); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
MailChimp
는 이메일을 전송하는 Infrastructure Service
이다.
다음으로 MailChimp
를 사용하는 BookingAppSerivce
를 만들어보도록 하자.
type BookingAppService struct {
bookingRepo BookingRepository
bookingDomainService BookingDomainService
emailService EmailSender
}
func (b *BookingAppService) CreateBooking(ctx context.Context, booking Booking) error {
u, ok := ctx.Value(accountCtxKey).(*chapter2.Customer)
if !ok {
return errors.New("invalid customer")
}
if u.UserID() != booking.userID.String() {
return errors.New("cannot create booking for other users")
}
if err := b.bookingDomainService.CreateBooking(ctx, booking); err != nil {
return fmt.Errorf("could not create booking: %w", err)
}
if err := b.bookingRepo.SaveBooking(ctx, booking); err != nil {
return fmt.Errorf("could not save booking: %w", err)
}
err := b.emailService.SendEmail(ctx, ...)
if err != nil {
// handle it.
}
return nil
}
아주 간단하게 EmailSender
라는 인터페이스를 추가하여 emailService
를 받고, 이 emailService
가 바로 MailChimp
가 되는 것이다. Application service에서 손쉽게 이메일을 처리할 수 있게 되었다.