Golang DDD를 배워보자 - 5일차 coffee application을 통한 실습

0

golang ddd

목록 보기
5/5

Monolithic application에 Domain-Driven Design 적용하기

1. Monolithic application이란?

monolithic application 또는 monolith는 enterprise application을 개발하는 가장 유명한 패턴으로, 시스템의 서로 다른 component들을 하나로 encapsulation한 application을 말한다.

[그림 5. 1]
monolithic application은 다음의 장점을 가진다.
1. 구조가 간단하기 때문에 문제를 디버깅하는 것도 매우 편하다.
2. deployment가 매우 편하고 필요조건들이 reasonable하다.
3. scale을 늘이고 줄이기 매우 편하다.

그러나 다음의 문제도 가지는데,

  1. 부팅시간 및 빌드 시간에 많은 시간이 소요될 수 있다. 이는 모든 component가 하나의 unit으로 연결되어 있기 때문이다.
  2. scaling 방법이 그리 많지 않다.
  3. 특정 기술에 너무 크게 의존하는 경향이 크다. ex, framework
  4. single unit에 너무 많은 기능들이 있어, 코드를 변경하기 쉽지 않다.

이제 간단한 monolithic application을 만들어서 DDD principle을 적용시켜보도록 하자.

2. Setting the scene

DDD를 도입하는 것은 어떠한 business logic(domain)을 해결하는 것이 중점이다. 우리는 CoffeeCo라는 가상의 점포를 생각하여 CoffeeCo에서 발생한 business domain을 해결해주도록 하자.

CoffeeCo는 다음의 특징을 갖는다.
1. CoffeeCo는 shop chain으로 50개 이상의 점포를 갖는다.
2. CoffeeCo는 coffee와 이와 관련된 물건들을 판매한다.
3. CoffeeCo는 각 점포마다에 특화된 음료들이 존재한다.
4. 각 점포마다 개별적인 offer가 있을 수 있는데, National Marketing Campaigns가 자주 열려서 item의 가격에 영향을 준다.
5. CoffeeCo는 loyalty 프로그램인 CoffeeBux라는 프로그램을 런칭했는데, 이는 10번 먹으면 1번을 무료로 제공해주는 행사이다. 단, 10번을 음료를 마시는 것은 어떤 점포이든지 상관이 없다.
6. CoffeeCo는 온라인 점포를 런칭하였고, 한달을 기준으로한 subscription을 제공하여 구매자에게 unlimited coffee를 매달 제공해주고 다른 음료를 할인해준다.

DDD의 가장 맨 처음 시작은 domain expert와 ubiquitous language를 만들고 정의하는 것이다. 여기서 domain expert는 CoffeeCo의 점원, 매니저, 손님, 커피 재료 공급회사 등이 될 것이다. 이들과 협의하여 정한 domain은 다음과 같다.

  1. Coffee lovers: CoffeeCo customer
  2. CoffeeBux: CoffeeCo의 loyalty 프로그램으로, 커피를 구매하거나 관련된 상품을 구매하면 Coffee lovers들은 1개씩 도장을 받는다. 10개를 받으면 1개의 coffee를 받을 수 있다.
  3. Tiny, medium, and massive: 음료의 오름차순 사이즈로 일부 음료는 하나의 사이즈만 가능하지만 일부는 모두 가능하다.
  4. Store: CoffeeCo 점포
  5. Product: CoffeeCo 커피와 그와 관련된 제품들
  6. Loyalty: CoffeeCo 프로그램
  7. Subscription: CoffeeCo 온라인 구독 서비스

minumum viable product(MVP)단계에서 최소한 구현되어야 할 사항들은 다음과 같다.

  1. CoffeeBux를 사용하여 음료 또는 액세서리를 구매할 수 있다.
  2. debit/credit card를 사용하여 음료 또는 액세서리를 구매할 수 있다.
  3. 현금으로 음료 또는 액세서리를 구매할 수 있다.
  4. 구매자들에게 CoffeeBux 도장을 제공할 수 있다.
  5. 지점마다의 할인이 다르다.
  6. 현재 USD로만 판매가 이루어지고 있지만, 이후에 다른 화폐도 도입해야한다.
  7. 음료는 현재는 하나의 사이즈만 제공하도록 하자.

3. Getting stated with our CoffeeCo system

이제 domain이 주어졌으니 CoffeeCo 프로그램을 만들어보도록 하자. 먼저 coffeeco 디렉터리를 만들고, internal폴더를 만들도록 하자.

coffeeco \
    internal \

    go.mod

internalgo의 특정한 directory로 외부에서 coffeeco에 접근할 때 public하게 접근하지 못하는 API들이다. 따라서 외부에서 접근하지 않도록 우리의 domain을 internal안에 만들어주면 된다. 먼저 loyalty 패키지를 만들어주도록 하자.

coffeeco \
    internal \
        loyalty \
    go.mod

loyalty 프로그램은 Coffee lovers가 있어야만 만들어질 수 있으므로, 먼저 Coffee lovers를 만들도록 하자.

coffeeco \
    internal \
        loyalty \
        coffeelover.go
    go.mod

internal에서 패키지를 따로 안 만든 이유는 coffeelover는 전역에서 필요로 하는 개념이기 때문이다.

다음으로 uuid를 사용하기 위해 다음의 모듈을 다운로드하도록 하자.

go get github.com/google/uuid
  • internal/coffeelover.go
package coffeeco

import "github.com/google/uuid"

type CoffeeLover struct {
	ID           uuid.UUID
	FirstName    string
	LastName     string
	EmailAddress string
}

CoffeeLover는 uuid를 가지고 있기 때문에 entity이다. 이는 CoffeeLover가 식별 가능해야 커피를 줄 수 있고, loyalty등을 제공할 수 있기 때문이다. 이 밖의 나머지 attributes들은 domain expert들과 협의하여 필요한 데이터를 넣었다고 할 수 있다.

다음으로, CoffeeCo회사의 점포인 store를 만들어주도록 하자. 이 역시도 domain에 속하기 때문에 internal안에 만들어주어야 한다. 또한, 전역적으로 사용할 것이 아니기 때문에 store패키지에 넣어주도록 한다.

coffeeco \
    internal \
        loyalty \
        store \
            store.go
        coffeelover.go
    go.mod

코드는 다음과 같다.

  • internal/store/store.go
package store

import "github.com/google/uuid"

type Store struct {
	ID       uuid.UUID
	Location string
}

Store는 식별 가능해야하기 때문에 entity이고 uuid.UUID를 갖는다.

Storecoffeecoffee관련된 제품을 제공해야한다. 그렇다면 coffeecoffee관련 제품은 value object일까 entity object일까?? 둘 다 가능할 수 있을 것이다. 지금은 value로 생각하여 처리하도록 해보자. 이유는 coffee product는 같은 value를 가지면 같은 제품으로 치부할 수 있고, domain concept를 설명하고 정량화하는 것이기 때문이다.

또한, 애매할 때는 value object로 만드는 것이 좋다. value object에서 entity로 바뀌는 것은 그리 어렵지 않으나, entity에서 value object로 변화하는 것은 어려울 수 있다.

/internal directory에 product.go파일을 만들도록 하자. product.go파일은 모든 package에서 전역적으로 쓰이기 때문에 따로 패키지를 두지 않고 coffeeco 패키지 안에 만들도록 하자. 즉, CoffeeLover와 같은 패키지라는 것이다.

coffeeco \
    internal \
        loyalty \
        store \
            store.go
        coffeelover.go
        product.go
    go.mod

다음으로 money에 대한 자료형이 필요하므로 아래의 모듈을 다운로드하도록 하자.

go get github.com/Rhymond/go-money
  • internal/product.go
package coffeeco

import "github.com/Rhymond/go-money"

type Product struct {
	ItemName  string
	BasePrice money.Money
}

BasePrice 이름은 임의로 짓는 것이 아니라, domain expert들과 협의하여 결정해야할 사항이다. ubiquitous languageBasePrice를 non-offer price로 결정한 것이다.

이제 product가 생겼으므로 storeproduct를 갖도록 하자.

  • internal/store/store.go
package store

import (
	coffeeco "coffeeco/internal"

	"github.com/google/uuid"
)

type Store struct {
	ID              uuid.UUID
	Location        string
	ProductsForSale []coffeeco.Product
}

Store가 적절하게 만들어진 것을 볼 수 있다. 다음으로 Purchase를 만들도록 하자. 이는 '구매기록'으로 이해하면 좋다. domain expert들은 구매기록이 왜 중요한지 모를 수 있기 때문에 개발자들이 domain expert들에게 충분한 설명을 하고, 까다로웠던 점이나 추가하고싶었던 기능들을 물어봐야 한다.

구매기록 역시 domain object이기 때문에 internal안에 만들어야 한다.

coffeeco \
    internal \
        loyalty \
        store \
            store.go
        purchase \
            purchase.go
        coffeelover.go
        product.go
    go.mod

코드는 다음과 같다.

  • internal/purchase/purchase.go
package purchase

import (
	coffeeco "coffeeco/internal"
	"coffeeco/internal/store"
	"time"

	"github.com/Rhymond/go-money"
	"github.com/google/uuid"
)

type Purchase struct {
	id                 uuid.UUID
	Store              store.Store
	ProductsToPurchase []coffeeco.Product
	total              money.Money
	PaymentMeans       payment.Means
	timeOfPurchase     time.Time
}

Purchase는 환불되기도 해야하고, 추적도 되어야하기 때문에 entity로 분류된다. 또한, transactional한 연산이 필요하므로 entity인 것이 당연하다. 따라서, uuid.UUID를 가지게 된다.

우린 아직 payment.Means을 구현하지 않았다. 구매를 하게된다면 카드로 할지 현금으로 할지가 중요하다. 그리고 카드로 한다했을 때 어떤 카드를 했는 지도 중요한 요소 중 하나이다. 결제수단을 의미하는 payment 패키지를 만들도록 하자.

payment 또한 domain이기 때문에 internal안에 만들어주기로 하자.

coffeeco \
    internal \
        loyalty \
        store \
            store.go
        purchase \
            purchase.go
        payment \
            means.go
        coffeelover.go
        product.go
    go.mod

internal/payment/means.go는 결제 수단을 의미하고, 다음의 코드를 만들 수 있을 것이다.

  • internal/payment/means.go
package payment

type Means string

const (
	MEANS_CARD     = "card"
	MEANS_CASH     = "cash"
	MEANS_COFFEEBUX  = "coffeebux"
)

type CardDetails struct {
	cardToken string
}

Means는 결제 수단을 의미하고, CardDetailscardToken attributes로 purchase(구매기록)가 이루어질 때의 구매를 확인했다는 토큰으로, 어떤 카드를 사용했는 지 표현하는 것이다.

구매기록(purchase)에서도 마찬가지로 카드 결제 시에 어떤 cardToken이 남아있는 지 알아야 하므로, 다음과 같이 Purchase 구조체에 CardToken을 추가할 수 있다.

package purchase

import (
	coffeeco "coffeeco/internal"
	"coffeeco/internal/payment"
	"coffeeco/internal/store"
	"time"

	"github.com/Rhymond/go-money"
	"github.com/google/uuid"
)

type Purchase struct {
	id                 uuid.UUID
	Store              store.Store
	ProductsToPurchase []coffeeco.Product
	total              money.Money
	PaymentMeans       payment.Means
	timeOfPurchase     time.Time
	CardToken          *string
}

CardToken*string으로 표현한 이유는 card가 아닌 다른 결제 수단으로 결제 시에는 해당 부분이 nil이기 때문이다.

다음으로 결제 수단을 보면 MEANS_COFFEEBUX가 있다. 도장 10개를 모으면, 한개를 공짜로 살 수 있기 때문이다. 이를 위햇거 loyalty package의 coffeebux를 추가하도록 하자.

coffeeco \
    internal \
        loyalty \
            coffeebux.go
        store \
            store.go
        purchase \
            purchase.go
        payment \
            means.go
        coffeelover.go
        product.go
    go.mod

coffeebux.go는 loyalty schema에 대한 logic을 포함한다. 다음과 같이 코드를 만들어보도록 하자.

  • internal/loyalty/coffeebux.go
package loyalty

import (
	coffeeco "coffeeco/internal"
	"coffeeco/internal/store"

	"github.com/google/uuid"
)

type CoffeeBux struct {
	ID                                    uuid.UUID
	store                                 store.Store
	coffeeLover                           coffeeco.CoffeeLover
	FreeDrinksAvailable                   int
	RemainingDrinkPurchasesUntilFreeDrink int
}

이제 필요한 domain object들은 충분히 만들었다. 다음으로 이 domain을 가지고 service를 만들어보도록 하자. 가장 중요하고 기본적인 service인 '구매' 서비스를 만들어보도록 하자. 'purchase'는 구매하려는 내역과, 물건들에 대한 검증이 먼저 필요하고, repository와 같은 다른 layer와의 긴밀한 연결과 여러 entity, value object에 대한 연산이 필요하다.

먼저 validation이 필요한데, validation부분은 domain object가 담당할 수 있는 로직이므로, Purchase부분에 validateAndEnrich함수를 만들어보도록 하자.

func (p *Purchase) validateAndEnrich() error {
	if len(p.ProductsToPurchase) == 0 {
		return errors.New("purchase must consist of at least one product")
	}

	p.total = *money.New(0, "USD")

	for _, v := range p.ProductsToPurchase {
		newTotal, _ := p.total.Add(&v.BasePrice)
		p.total = *newTotal
	}

	if p.total.IsZero() {
		return errors.New("likely mistake; purchase should never be 0. Please validate")
	}

	p.id = uuid.New()
	p.timeOfPurchase = time.Now()

	return nil
}

validateAndEnrich가 이루어지면서 p *Purchase의 값 일부들을 업데이트하는 것을 볼 수 있다. 가령 total, id, timeOfPurchase인데 이러한 부분들은 사용자나, 종업원이 입력하는 것이 아니라 시스템이 자체적으로 입력되는 부분이기 때문에 Purchase domain자체에서 업데이트하도록 한 것이다.

p.totalUSD화폐를 쓰는데 나중에 다른 화폐들도 지원하도록 수정이 필요하다.

이제, 구매하는 service를 만들어보자. purchase.go에서 다음의 코드를 추가하도록 하자.

  • internal/purchase/purchase.go
type CardChargeService interface {
	ChargeCard(ctx context.Context, amount money.Money, cardToken string) error
}

type Service struct {
	cardService   CardChargeService
	purchasesRepo Repository
}

func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase) error {
	if err := purchase.validateAndEnrich(); err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.CardToken); err != nil {
			return errors.New("card charge failed, cancelling purchase")
		}
	case payment.MEANS_CASH:
		// TODO
	default:
		return errors.New("unknown payment type")
	}

	if err := s.purchasesRepo.Store(ctx, *purchase); err != nil {
		return errors.New("failed to store purchase")
	}

	return nil
}

아직 Repository는 없지만 purchase가 정상적으로 이루어지면 Repository를 통해서 정보를 저장하도록 한다.

purchase.validateAndEnrich는 purchase의 검증과 동시에 일부 데이터를 설정해준다. 이후 결제 수단이 card라면 cardServiceChargeCard를 실행시키도록 하여, card 결제를 완료한다. 만약 card결제가 실패하면 error를 반환하도록 하게한다.

CardChargeService interface는 purchase에서 구현할 사항이 아니라, purchase에서 요구하는 인터페이스로 payment쪽에서 이를 구현해야한다.

이제 purchase package의 repository.go를 만들도록 하자.

coffeeco \
    internal \
        loyalty \
            coffeebux.go
        store \
            store.go
        purchase \
            purchase.go
            repository.go
        payment \
            means.go
        coffeelover.go
        product.go
    go.mod

코드는 다음과 같다.

  • internal/purchase/repository.go
package purchase

import "context"

type Repository interface {
	Store(ctx context.Context, purchase Purchase) error
}

Repository를 인터페이스로 만들어서 어떤 DB를 써도 Repository를 사용하는 client측에서는 부담이 없도록 만드는 것이다. 즉, mysql을 쓰다가 mongodb로 바꾸어도 purchase쪽에는 영향을 미치지 않도록 하는 것이다.

이제 Repository를 구현하는 구현체를 만들어보도록 하자.

4. implementing out product repository

mongodb를 사용한다고 해보자.

먼저, mongodb를 container를 설치하고 실행해보도록 하자.

docker pull mongo
docker run --name mongodb -d -p 27017:27017 mongo:latest

이렇게 mongodb container가 만들어지고 27017 port로 열리게 된다.

다음으로는 golang에서 mongodb에 접근하는 driver를 설치하자.

go get go.mongodb.org/mongo-driver/mongo

이제 repository mongo 구현체를 만들면 된다. repository.go에 다음의 코드를 만들도록 하자.

  • internal/purchase/repository.go
package purchase

import (
	"context"
	"fmt"

	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

type Repository interface {
	Store(ctx context.Context, purchase Purchase) error
}

type MongoRepository struct {
	purchases *mongo.Collection
}

func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
	client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
	if err != nil {
		return nil, fmt.Errorf("failed to create a mongo client: %w", err)
	}

	purchase := client.Database("coffeeco").Collection("purchases")

	return &MongoRepository{
		purchases: purchase,
	}, nil
}

MongoRepositoryRepository의 구현체로 purchases라는 mongodb collection을 가지고 있다. 이를 통해 mongodb에 connection이 가능한 것이다. NewMongoRepo는 factory pattern으로 MongoRepository를 만들고, 필요한 connection 설정을 해준다.

다음으로 MongoRepositoryRespositoryStore함수를 구현하도록 하자.

  • internal/purchase/repository.go
func (mr *MongoRepository) Store(ctx context.Context, purchase Purchase) error {
	mongoP := toMongoPurchase(purchase)

	_, err := mr.purchases.InsertOne(ctx, mongoP)
	if err != nil {
		return fmt.Errorf("failed to persist purchase: %w", err)
	}

	return nil
}

type mongoPurchase struct {
	id                 uuid.UUID
	store              store.Store
	productsToPurchase []coffeeco.Product
	total              money.Money
	paymentMeans       payment.Means
	timeOfPurchase     time.Time
	cardToken          *string
}

func toMongoPurchase(p Purchase) mongoPurchase {
	return mongoPurchase{
		id:                 p.id,
		store:              p.Store,
		productsToPurchase: p.ProductsToPurchase,
		total:              p.total,
		paymentMeans:       p.PaymentMeans,
		timeOfPurchase:     p.timeOfPurchase,
		cardToken:          p.CardToken,
	}
}

MongoRepositoryStoretoMongoPurchase로 데이터 베이스에 저장하기 편하게 Purchase를 변경한 뒤 데이터를 저장한다. mopngoPurchase로 데이터를 변경한 이유는 이들의 domain이 다르기 때문이다. 즉, database design에서와 DDD design의 entity가 서로 다르기 때문이다. 물론 여기서는 정보가 서로 같다.

5. Addding an infrastructure service for payment handling

결제 서비스를 제공하기 위해 Stripe를 사용하도록 하자. mongo repository와 마찬가지로, Stripe를 우리의 domain과 분리시켜 결합도를 낮추도록 하자. 즉, Stripe는 특정 회사의 API이므로 언제든 바뀔 수 있다는 것을 염두해두고 구현체와 사용부분을 나누는 것이다.

다음과 같이 stripe.go를 만들도록 하자.

payment/
├── means.go
└── stripe.go

stripe api를 설치해주도록 하자.

go get -u github.com/stripe/stripe-go/v73

아래의 code를 stripe.go에 넣어주자.

  • internal/payment/stripe.go
package payment

import (
	"errors"

	"github.com/stripe/stripe-go/v73/client"
)

type StripeService struct {
	stripeClient *client.API
}

func NewStripeService(apiKey string) (*StripeService, error) {
	if apiKey == "" {
		return nil, errors.New("API key cannot be nil")
	}

	sc := &client.API{}
	sc.Init(apiKey, nil)
	return &StripeService{stripeClient: sc}, nil
}

우리는 이전에 internal/purchase/purchase.go 파일에 다음의 interface를 만들었다.

type CardChargeService interface {
	ChargeCard(ctx context.Context, amount money.Money, cardToken string) error
}

golang의 interface는 언제나 사용하는 쪽에 있어야 한다. 따라서, 구현부에서 가지고 있지 않아야 한다는 것이다. 이 interface를 통해서 card 결제를 이루는데, stripe가 다음의 interface를 만족하도록 ChargeCard 함수를 만들어주도록 하자.

  • internal/payment/stripe.go
...
func (s StripeService) ChargeCard(ctx context.Context, amount money.Money, cardToken string) error {
	params := &stripe.ChargeParams{
		Amount:   stripe.Int64(amount.Amount()),
		Currency: stripe.String(string(stripe.CurrencyUSD)),
		Source:   &stripe.PaymentSourceSourceParams{Token: stripe.String(cardToken)},
	}

	_, err := charge.New(params)
	if err != nil {
		return fmt.Errorf("failed to create a charge:%w", err)
	}
	return nil
}

결제에 필요한 함수들이 정의되었다.

Paying with CoffeeBux

다음으로 coffee를 10잔 이상 구매한 고객을 위한 쿠폰을 발급해주도록 하자.

  • internal/loyalty/coffeebux.go
...
func (c *CoffeeBux) AddStamp() {
	if c.RemainingDrinkPurchasesUntilFreeDrink == 1 {
		c.RemainingDrinkPurchasesUntilFreeDrink = 10
		c.FreeDrinksAvailable += 1
	} else {
		c.RemainingDrinkPurchasesUntilFreeDrink--
	}
}

coffee를 구매하면 쿠폰까지 남은 횟수를 세어주고, 남은 횟수를 충족하면 free drink 쿠폰을 발급해주는 것이다.

이제 purchase.go에 가서 purchase 함수를 다음과 같이 수정하자.

  • internal/purchase/purchase.go
func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error {

	...

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}

CompletePurchase함수의 인자로 CoffeeBuxCard를 받도록 하였다. pointer 타입으로 받은 이유는 loyalty card는 option이며 필수 요건이 아니기 때문에 손님이 없을 수 있기 때문이다. 따라서, nil을 주어서 손님이 loyalty card가 없는 경우를 나타내려고 한 것이다.

이제 CoffeeBux 쿠폰으로 음료를 사는 함수를 만들어주도록 하자.

  • internal/loyalty/coffeebux.go
...
func (c *CoffeeBux) Pay(ctx context.Context, purchases []coffeeco.Product) error {
	lp := len(purchases)

	if lp == 0 {
		return errors.New(("nothing to buy"))
	}

	if c.FreeDrinksAvailable < lp {
		return fmt.Errorf("not enough coffeeBux to cover entire purchase. Have %d, need %d", len(purchases), c.FreeDrinksAvailable)
	}
	c.FreeDrinksAvailable = c.FreeDrinksAvailable - lp
	return nil
}

purchases[]coffeeco.Product 타입으로 받은 것을 보도록하자. 왜냐하면 purchase.Purchase을 받으면 purchase package와 coffeebux package가 cycle이 생기기 때문이다. 따라서, 공용으로 쓰이는 coffeeco.Product을 가져오도록 하여, cycle을 피한 것이다.

CoffeeBuxPay함수를 통해서 쿠폰만큼 purchase에서 제거하고 사용하도록 하는 것이다.

이제 purchase.gocoffeeBuxCard.Pay를 사용할 수 있도록 하자.

  • internal/purchase/purchase.go
func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error {
	if err := purchase.validateAndEnrich(); err != nil {
		return err
	}

	switch purchase.PaymentMeans {
	case payment.MEANS_CARD:
		if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.CardToken); err != nil {
			return errors.New("card charge failed, cancelling purchase")
		}
	case payment.MEANS_CASH:
		// TODO
	case payment.MEANS_COFFEEBUX:
		// coffeebux card가 없으면 호출도 안된다.
		if err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase); err != nil {
			return fmt.Errorf("failed to charge loyalty card: %w", err)
		}
	default:
		return errors.New("unknown payment type")
	}

	if err := s.purchasesRepo.Store(ctx, *purchase); err != nil {
		return errors.New("failed to store purchase")
	}

	if coffeeBuxCard != nil {
		coffeeBuxCard.AddStamp()
	}

	return nil
}

payment.MEANS_COFFEEBUX가 추가되고 coffeeBuxCard.Pay가 호출되는 것을 볼 수 있다.

물론 아직 이상한 로직이 하나 있는데, coffeeBuxCard를 통해서 결제를 해도 쿠폰 스탬프를 찍어준다는 것이다. 이것이 정상적인 로직인지 아닌지에 대해서는 도메인 전문가와의 협의가 필요하다.

다음 section으로 store마다의 할인율을 제공하도록 하는 code를 만들도록 하자.

Adding store-specific discounts

store별로의 할인을 설정하기 위해서 우리는 store에 repository layer를 두도록 하자.

  • internal/store/repository.go
package store

import (
	"context"
	"errors"
	"fmt"

	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

var ErrNoDiscount = errors.New("no discount for store")

type Repository interface {
	GetStoreDiscount(ctx context.Context, storeID uuid.UUID) (int64, error)
}

type MongoRepository struct {
	storeDiscounts *mongo.Collection
}

func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
	clinet, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))

	if err != nil {
		return nil, fmt.Errorf("failed to create a mongo client: %w", err)
	}

	discounts := clinet.Database("coffeeco").Collection("store_discounts")

	return &MongoRepository{
		storeDiscounts: discounts,
	}, nil
}

func (m MongoRepository) GetStoreDiscount(ctx context.Context, storeID uuid.UUID) (int64, error) {
	var discount int64
	if err := m.storeDiscounts.FindOne(ctx, bson.D{bson.E{Key: "store_id", Value: storeID.String()}}).Decode(&discount); err != nil {
		if err == mongo.ErrNoDocuments {
			return 0, ErrNoDiscount
		}
		return 0, fmt.Errorf("failed to find discount for store: %w", err)
	}
	return discount, nil
}

다음의 MongoRepository를 통해서 mongoDB와 연결할 수 있고, coffeecostore_discounts collection을 가져온다. GetStoreDiscount는 storeID를 통해서 해당 storeID를 가진 store의 할인율을 얻어오는 것이다.

만약 해당 store가 없다면 ErrNoDiscount을 반환하도록 한다.

이제 purchase.go에 purchase와 repository를 함께 제공하는 service를 다음과 같이 수정하도록 하자.

  • internal/purchase/purchase.go
type CardChargeService interface {
	ChargeCard(ctx context.Context, amount money.Money, cardToken string) error
}

type StoreService interface {
	GetStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID) (float32, error)
}

type Service struct {
	cardService  CardChargeService
	purchasesRepo Repository
	storeService StoreService
}

StoreService interface가 추가된 것을 볼 수 있다. StoreService안의 store객체는 위에서 만든 store domain의 repository를 사용하여 각 store별 discount를 제공해야한다.

  • internal/store/store.go
...
type Service struct {
	repo Repository
}

func NewService(repo Repository) *Service {
	return &Service{repo: repo}
}

func (s Service) GetStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID) (float32, error) {
	dis, err := s.repo.GetStoreDiscount(ctx, storeID)
	if err != nil {
		return 0, err
	}
	return float32(dis), nil
}

이제 store의 Service를 purchase에 제공해주도록 하자.

  • internal/purchase/purchase.go
...
func NewService(cardService CardChargeService, purchasesRepo Repository, storeService StoreService) *Service {
	return &Service{cardService: cardService, purchasesRepo: purchasesRepo, storeService: storeService}
}
...

다음으로 purchase service에 store별의 discount를 적용시키도록 해보자.

  • internal/purchase/purchase.go
...
func (s *Service) calculateStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID, purchase *Purchase) error {
	discount, err := s.storeService.GetStoreSpecificDiscount(ctx, storeID)
	if err != nil && err != store.ErrNoDiscount {
		return fmt.Errorf("failed to get discount: %w", err)
	}

	purchasePrice := purchase.total
	if discount > 0 {
		purchase.total = *purchasePrice.Multiply(int64(100 - discount))
	}

	return nil
}
...

이제 calculateStoreSpecificDiscountCompletePurchase에 사용하여, 결제할 때 할인율이 적용되도록 하자.

  • internal/purchase/purchase.go
func (s Service) CompletePurchase(ctx context.Context, sotreID uuid.UUID, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error {
	if err := purchase.validateAndEnrich(); err != nil {
		return err
	}

	if err := s.calculateStoreSpecificDiscount(ctx, storeID, purchase); err != nil {
		return err
	}
	...
}

purchase의 total값을 store에 따라 discount가 적용된 것을 볼 수 있다. store에 대한 식별을 위해 sotreIDCompletePurchase의 매개변수로 추가한 것을 볼 수 있다.

이제 마지막으로 main.go에 들어가서 purchase의 Service를 새로 생성해 실행하도록 하자.

  • main.go
package main

import (
	coffeeco "coffeeco/internal"
	"coffeeco/internal/payment"
	"coffeeco/internal/purchase"
	"coffeeco/internal/store"
	"context"
	"log"

	"github.com/Rhymond/go-money"
	"github.com/google/uuid"
)

func main() {
	ctx := context.Background()

	stripeTestAPIKey := ""
	cardToken := "tok-visa"

	mongoConString := "mingodb://root:example@localhost:27017"
	csvc, err := payment.NewStripeService(stripeTestAPIKey)

	if err != nil {
		log.Fatal(err)
	}

	prepo, err := purchase.NewMongoRepo(ctx, mongoConString)
	if err != nil {
		log.Fatal(err)
	}

	sRepo, err := store.NewMongoRepo(ctx, mongoConString)
	if err != nil {
		log.Fatal(err)
	}

	sSvc := store.NewService(sRepo)
	svc := purchase.NewService(csvc, prepo, sSvc)

	someStoreID := uuid.New()

	pur := &purchase.Purchase{
		CardToken: &cardToken,
		Store: store.Store{
			ID: someStoreID,
		},
		ProductsToPurchase: []coffeeco.Product{{
			ItemName:  "item1",
			BasePrice: *money.New(3300, "USD"),
		}},
		PaymentMeans: payment.MEANS_CARD,
	}
	if err := svc.CompletePurchase(ctx, someStoreID, pur, nil); err != nil {
		log.Fatal(err)
	}

	log.Println("purchase was successful")
}

완전하게 동작하는 것을 볼 수 있을 것이다. 특히 각 domain에 대한 repository와 service의 동작관계에 대해서 잘 파악해보도록 하자. 이처럼 domain 각각에 repository와 service를 가지게 되는 구조는 domain간의 cycle을 없애고, 공통화된 code를 만드는데 너무 많은 시간을 들이지 않아도 된다는 장점이 있다.

전체 directory구조는 다음과 같다.

tree
.
├── go.mod
├── go.sum
├── internal
│   ├── coffeelover.go
│   ├── loyalty
│   │   └── coffeebux.go
│   ├── payment
│   │   ├── means.go
│   │   └── stripe.go
│   ├── product.go
│   ├── purchase
│   │   ├── purchase.go
│   │   └── repository.go
│   └── store
│       ├── repository.go
│       └── store.go
└── main.go

https://github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/tree/main/chapter5 해당 link에서 전체 code를 볼 수 있다.

0개의 댓글