Golang DDD를 배워보자 - 4일차 Factory, Repository, Service

0

golang ddd

목록 보기
4/5

Exploring Factories, Repositories, and Services

1. Factory pattern

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에서 보장해주고 있는 것이다.

2. Implementing the repository pattern in Golang

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 repositoryCustomer 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
}

BookingRepositoryBooking을 저장하고 삭제하는 기능을 가지며, 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에 대해서 알아보도록 하자.

3. Understanding services

DDD에서는 우리의 코드를 조직화하기 위해서 다양한 service들을 사용할 수 있다. application services, domain services, infrastructure services가 있다. 이 3가지 service에 대해서 알아보고, 언제 이들이 유용하지 알아보도록 하자.

3.1. Domain services

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를 계산할 때

다음의 예시 코드를 보도록 하자.

  • main.go
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부분에 문제가 있다. AddToCartShoppingCart entity가 직접적으로 Product라는 entity에 접근한다. entity를 직접적으로 interaction하지 않아야하는 이유는 서로의 bounded context가 겹쳐 분리가 모호해질 수 있기 때문인데, 즉 하나의 entity안의 비지니스 로직 안에 또 다른 entity의 비지니스 로직이 실행될 수 있기 때문이다. 그렇기 때문에 Product를 직접적으로 참조하기 보다는 Product ID와 같은 식별자를 참조하는 것이 좋은 것이다.

위 코드에서 AddToCart안에 ProductCanBeBought라는 비지니스 로직을 실행한다. 또한, AddToCart가 진짜로 ShoppingCart의 business logic이냐고한다면, 굉장히 모호하다. ShoppoingCart에 대한 설정도 아니라, 물건을 담는 행위ShoppingCart에서 담당하기 보다는 aggregatorservice에서 하는 것이 적절해보이는 것이다. 즉, 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를 사용해야한다.

3.2. Application services

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에서 처리하도록 하는 것이다.

다음의 예시를 보도록 하자.

  • main.go
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로 BookingDomainServiceBookinRepository로 구성된다. 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인데, 반드시 필요하진 않은 경우에 사용한다. 다음의 물음에 대답해보자.

  1. 만약 해당 service가 없으면 domain model의 실행에 영향을 주는가?? 그렇다면 이는 domain service에 해당한다.
  2. 만약 해당 service가 없으면 appliction의 실행에 영향을 주는가?? 그렇다면 이는 application service에 해당한다.

이 나머지는 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에서 손쉽게 이메일을 처리할 수 있게 되었다.

0개의 댓글