monolithic application 또는 monolith는 enterprise application을 개발하는 가장 유명한 패턴으로, 시스템의 서로 다른 component들을 하나로 encapsulation한 application을 말한다.
[그림 5. 1]
monolithic application은 다음의 장점을 가진다.
1. 구조가 간단하기 때문에 문제를 디버깅하는 것도 매우 편하다.
2. deployment가 매우 편하고 필요조건들이 reasonable하다.
3. scale을 늘이고 줄이기 매우 편하다.
그러나 다음의 문제도 가지는데,
이제 간단한 monolithic application을 만들어서 DDD principle을 적용시켜보도록 하자.
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은 다음과 같다.
Coffee lovers
: CoffeeCo
customerCoffeeBux
: CoffeeCo
의 loyalty 프로그램으로, 커피를 구매하거나 관련된 상품을 구매하면 Coffee lovers
들은 1개씩 도장을 받는다. 10개를 받으면 1개의 coffee를 받을 수 있다.Tiny, medium, and massive
: 음료의 오름차순 사이즈로 일부 음료는 하나의 사이즈만 가능하지만 일부는 모두 가능하다.Store
: CoffeeCo
점포Product
: CoffeeCo
커피와 그와 관련된 제품들Loyalty
: CoffeeCo
프로그램Subscription
: CoffeeCo
온라인 구독 서비스minumum viable product(MVP)단계에서 최소한 구현되어야 할 사항들은 다음과 같다.
CoffeeBux
를 사용하여 음료 또는 액세서리를 구매할 수 있다.CoffeeBux
도장을 제공할 수 있다.이제 domain이 주어졌으니 CoffeeCo
프로그램을 만들어보도록 하자. 먼저 coffeeco
디렉터리를 만들고, internal
폴더를 만들도록 하자.
coffeeco \
internal \
go.mod
internal
은 go
의 특정한 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
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
코드는 다음과 같다.
package store
import "github.com/google/uuid"
type Store struct {
ID uuid.UUID
Location string
}
Store
는 식별 가능해야하기 때문에 entity이고 uuid.UUID
를 갖는다.
Store
는 coffee
와 coffee
관련된 제품을 제공해야한다. 그렇다면 coffee
와 coffee
관련 제품은 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
package coffeeco
import "github.com/Rhymond/go-money"
type Product struct {
ItemName string
BasePrice money.Money
}
BasePrice
이름은 임의로 짓는 것이 아니라, domain expert들과 협의하여 결정해야할 사항이다. ubiquitous language
로 BasePrice
를 non-offer price로 결정한 것이다.
이제 product
가 생겼으므로 store
가 product
를 갖도록 하자.
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
코드는 다음과 같다.
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
는 결제 수단을 의미하고, 다음의 코드를 만들 수 있을 것이다.
package payment
type Means string
const (
MEANS_CARD = "card"
MEANS_CASH = "cash"
MEANS_COFFEEBUX = "coffeebux"
)
type CardDetails struct {
cardToken string
}
Means
는 결제 수단을 의미하고, CardDetails
는 cardToken
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을 포함한다. 다음과 같이 코드를 만들어보도록 하자.
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.total
로 USD
화폐를 쓰는데 나중에 다른 화폐들도 지원하도록 수정이 필요하다.
이제, 구매하는 service를 만들어보자. 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
라면 cardService
의 ChargeCard
를 실행시키도록 하여, 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
코드는 다음과 같다.
package purchase
import "context"
type Repository interface {
Store(ctx context.Context, purchase Purchase) error
}
Repository
를 인터페이스로 만들어서 어떤 DB를 써도 Repository
를 사용하는 client측에서는 부담이 없도록 만드는 것이다. 즉, mysql을 쓰다가 mongodb로 바꾸어도 purchase
쪽에는 영향을 미치지 않도록 하는 것이다.
이제 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
에 다음의 코드를 만들도록 하자.
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
}
MongoRepository
는 Repository
의 구현체로 purchases
라는 mongodb collection을 가지고 있다. 이를 통해 mongodb에 connection이 가능한 것이다. NewMongoRepo
는 factory pattern으로 MongoRepository
를 만들고, 필요한 connection 설정을 해준다.
다음으로 MongoRepository
로 Respository
의 Store
함수를 구현하도록 하자.
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,
}
}
MongoRepository
의 Store
는 toMongoPurchase
로 데이터 베이스에 저장하기 편하게 Purchase
를 변경한 뒤 데이터를 저장한다. mopngoPurchase
로 데이터를 변경한 이유는 이들의 domain이 다르기 때문이다. 즉, database design에서와 DDD design의 entity가 서로 다르기 때문이다. 물론 여기서는 정보가 서로 같다.
결제 서비스를 제공하기 위해 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
에 넣어주자.
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
함수를 만들어주도록 하자.
...
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
}
결제에 필요한 함수들이 정의되었다.
다음으로 coffee를 10잔 이상 구매한 고객을 위한 쿠폰을 발급해주도록 하자.
...
func (c *CoffeeBux) AddStamp() {
if c.RemainingDrinkPurchasesUntilFreeDrink == 1 {
c.RemainingDrinkPurchasesUntilFreeDrink = 10
c.FreeDrinksAvailable += 1
} else {
c.RemainingDrinkPurchasesUntilFreeDrink--
}
}
coffee를 구매하면 쿠폰까지 남은 횟수를 세어주고, 남은 횟수를 충족하면 free drink 쿠폰을 발급해주는 것이다.
이제 purchase.go
에 가서 purchase
함수를 다음과 같이 수정하자.
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
쿠폰으로 음료를 사는 함수를 만들어주도록 하자.
...
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을 피한 것이다.
CoffeeBux
의 Pay
함수를 통해서 쿠폰만큼 purchase에서 제거하고 사용하도록 하는 것이다.
이제 purchase.go
에 coffeeBuxCard.Pay
를 사용할 수 있도록 하자.
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를 만들도록 하자.
store별로의 할인을 설정하기 위해서 우리는 store에 repository
layer를 두도록 하자.
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와 연결할 수 있고, coffeeco
의 store_discounts
collection을 가져온다. GetStoreDiscount
는 storeID를 통해서 해당 storeID를 가진 store의 할인율을 얻어오는 것이다.
만약 해당 store가 없다면 ErrNoDiscount
을 반환하도록 한다.
이제 purchase.go에 purchase와 repository를 함께 제공하는 service를 다음과 같이 수정하도록 하자.
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를 제공해야한다.
...
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에 제공해주도록 하자.
...
func NewService(cardService CardChargeService, purchasesRepo Repository, storeService StoreService) *Service {
return &Service{cardService: cardService, purchasesRepo: purchasesRepo, storeService: storeService}
}
...
다음으로 purchase service에 store별의 discount를 적용시키도록 해보자.
...
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
}
...
이제 calculateStoreSpecificDiscount
를 CompletePurchase
에 사용하여, 결제할 때 할인율이 적용되도록 하자.
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에 대한 식별을 위해 sotreID
를 CompletePurchase
의 매개변수로 추가한 것을 볼 수 있다.
이제 마지막으로 main.go
에 들어가서 purchase
의 Service를 새로 생성해 실행하도록 하자.
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를 볼 수 있다.