├── .gitignore ├── .env ├── docker └── entrypoint.sh ├── Dockerfile ├── pkg ├── orders │ ├── domain │ │ └── orders │ │ │ ├── repository.go │ │ │ ├── product.go │ │ │ ├── order.go │ │ │ ├── address.go │ │ │ ├── address_test.go │ │ │ └── order_test.go │ ├── interfaces │ │ ├── private │ │ │ ├── intraprocess │ │ │ │ └── payments.go │ │ │ └── http │ │ │ │ └── orders.go │ │ └── public │ │ │ └── http │ │ │ └── orders.go │ ├── infrastructure │ │ ├── payments │ │ │ ├── intraprocess.go │ │ │ └── amqp.go │ │ ├── orders │ │ │ ├── memory.go │ │ │ └── memory_test.go │ │ └── shop │ │ │ ├── intraprocess_test.go │ │ │ ├── intraprocess.go │ │ │ └── http.go │ └── application │ │ └── orders.go ├── shop │ ├── domain │ │ └── products │ │ │ ├── repository.go │ │ │ ├── products.go │ │ │ └── products_test.go │ ├── interfaces │ │ ├── private │ │ │ ├── intraprocess │ │ │ │ ├── products_test.go │ │ │ │ └── products.go │ │ │ └── http │ │ │ │ └── products.go │ │ └── public │ │ │ └── http │ │ │ └── products.go │ ├── fixtures.go │ ├── infrastructure │ │ └── products │ │ │ ├── memory.go │ │ │ └── memory_test.go │ └── application │ │ └── products.go ├── common │ ├── cmd │ │ ├── router.go │ │ ├── wait.go │ │ └── signals.go │ ├── price │ │ ├── price.go │ │ └── price_test.go │ └── http │ │ └── error.go └── payments │ ├── infrastructure │ └── orders │ │ ├── intraprocess.go │ │ └── http.go │ ├── application │ └── payments.go │ └── interfaces │ ├── intraprocess │ └── orders.go │ └── amqp │ └── orders.go ├── go.mod ├── Makefile ├── cmd ├── microservices │ ├── payments │ │ └── main.go │ ├── shop │ │ └── main.go │ └── orders │ │ └── main.go └── monolith │ └── main.go ├── README.md ├── docker-compose.yml ├── tests └── acceptance_test.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GO_PROJECT_DIR=/go/src/github.com/ThreeDotsLabs/monolith-microservice-shop -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -x 3 | 4 | go mod download && reflex -s -r .go go run "$1" 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20 2 | 3 | WORKDIR /go/src/github.com/ThreeDotsLabs/monolith-microservice-shop 4 | COPY . . 5 | 6 | RUN go mod download 7 | RUN go install github.com/cespare/reflex@latest 8 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/repository.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import "errors" 4 | 5 | var ErrNotFound = errors.New("order not found") 6 | 7 | type Repository interface { 8 | Save(*Order) error 9 | ByID(ID) (*Order, error) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/shop/domain/products/repository.go: -------------------------------------------------------------------------------- 1 | package products 2 | 3 | import "errors" 4 | 5 | var ErrNotFound = errors.New("product not found") 6 | 7 | type Repository interface { 8 | Save(*Product) error 9 | ByID(ID) (*Product, error) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/common/cmd/router.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/go-chi/chi" 5 | "github.com/go-chi/chi/middleware" 6 | ) 7 | 8 | func CreateRouter() *chi.Mux { 9 | r := chi.NewRouter() 10 | r.Use(middleware.Logger) 11 | 12 | return r 13 | } -------------------------------------------------------------------------------- /pkg/common/cmd/wait.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "time" 7 | ) 8 | 9 | func WaitForService(host string) { 10 | log.Printf("waiting for %s", host) 11 | 12 | for { 13 | log.Printf("testing connection to %s", host) 14 | conn, err := net.Dial("tcp", host) 15 | if err == nil { 16 | _ = conn.Close() 17 | log.Printf("%s is up!", host) 18 | return 19 | } 20 | time.Sleep(time.Millisecond * 500) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/common/cmd/signals.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | ) 9 | 10 | func Context() context.Context { 11 | ctx, cancel := context.WithCancel(context.Background()) 12 | 13 | go func() { 14 | sigs := make(chan os.Signal, 1) 15 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 16 | 17 | select { 18 | case <-sigs: 19 | cancel() 20 | case <-ctx.Done(): 21 | return 22 | } 23 | }() 24 | 25 | return ctx 26 | } 27 | -------------------------------------------------------------------------------- /pkg/payments/infrastructure/orders/intraprocess.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/private/intraprocess" 4 | 5 | type IntraprocessService struct { 6 | paymentsInterface intraprocess.OrdersInterface 7 | } 8 | 9 | func NewIntraprocessService(paymentsInterface intraprocess.OrdersInterface) IntraprocessService { 10 | return IntraprocessService{paymentsInterface} 11 | } 12 | 13 | func (o IntraprocessService) MarkOrderAsPaid(orderID string) error { 14 | return o.paymentsInterface.MarkOrderAsPaid(orderID) 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ThreeDotsLabs/monolith-microservice-shop 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/go-chi/chi v3.3.2+incompatible 7 | github.com/go-chi/render v1.0.0 8 | github.com/pkg/errors v0.8.0 9 | github.com/satori/go.uuid v1.2.0 10 | github.com/streadway/amqp v0.0.0-20180112231532-a354ab84f102 11 | github.com/stretchr/testify v1.2.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.0 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | golang.org/x/net v0.8.0 // indirect 18 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /pkg/orders/interfaces/private/intraprocess/payments.go: -------------------------------------------------------------------------------- 1 | package intraprocess 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/application" 5 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 6 | ) 7 | 8 | type OrdersInterface struct { 9 | service application.OrdersService 10 | } 11 | 12 | func NewOrdersInterface(service application.OrdersService) OrdersInterface { 13 | return OrdersInterface{service} 14 | } 15 | 16 | func (p OrdersInterface) MarkOrderAsPaid(orderID string) error { 17 | return p.service.MarkOrderAsPaid(application.MarkOrderAsPaidCommand{orders.ID(orderID)}) 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | qa: 2 | # "Errors unhandled" check is made by errcheck 3 | gometalinter \ 4 | --vendor \ 5 | --deadline=60s \ 6 | --exclude="composite literal uses unkeyed fields" \ 7 | --exclude="should have comment or be unexported" \ 8 | --exclude="Errors unhandled" \ 9 | ./... 10 | go-cleanarch 11 | 12 | up: 13 | docker compose up 14 | 15 | docker-test: 16 | docker compose exec tests go test -v ./tests/... 17 | 18 | docker-test-monolith: 19 | docker compose exec tests go test -v -run "/monolith" ./tests/... 20 | 21 | docker-test-microservices: 22 | docker compose exec tests go test -v -run "/microservices" ./tests/... 23 | -------------------------------------------------------------------------------- /pkg/shop/interfaces/private/intraprocess/products_test.go: -------------------------------------------------------------------------------- 1 | package intraprocess 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestProductFromDomainProduct(t *testing.T) { 12 | productPrice := price.NewPriceP(42, "USD") 13 | domainProduct, err := products.NewProduct("123", "name", "desc", productPrice) 14 | assert.NoError(t, err) 15 | 16 | p := ProductFromDomainProduct(*domainProduct) 17 | 18 | assert.EqualValues(t, Product{ 19 | "123", 20 | "name", 21 | "desc", 22 | productPrice, 23 | }, p) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/payments/infrastructure/orders/http.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type HTTPClient struct { 11 | address string 12 | } 13 | 14 | func NewHTTPClient(address string) HTTPClient { 15 | return HTTPClient{address} 16 | } 17 | 18 | func (h HTTPClient) MarkOrderAsPaid(orderID string) error { 19 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/orders/%s/paid", h.address, orderID), nil) 20 | if err != nil { 21 | return errors.Wrap(err, "cannot create request") 22 | } 23 | 24 | resp, err := http.DefaultClient.Do(req) 25 | if err != nil { 26 | return errors.Wrap(err, "request to orders failed") 27 | } 28 | 29 | return resp.Body.Close() 30 | } 31 | -------------------------------------------------------------------------------- /pkg/shop/fixtures.go: -------------------------------------------------------------------------------- 1 | package shop 2 | 3 | import ( 4 | shop_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/application" 5 | ) 6 | 7 | func LoadShopFixtures(productsService shop_app.ProductsService) error { 8 | err := productsService.AddProduct(shop_app.AddProductCommand{ 9 | ID: "1", 10 | Name: "Product 1", 11 | Description: "Some extra description", 12 | PriceCents: 422, 13 | PriceCurrency: "USD", 14 | }) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | return productsService.AddProduct(shop_app.AddProductCommand{ 20 | ID: "2", 21 | Name: "Product 2", 22 | Description: "Another extra description", 23 | PriceCents: 333, 24 | PriceCurrency: "EUR", 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/payments/intraprocess.go: -------------------------------------------------------------------------------- 1 | package payments 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 5 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/interfaces/intraprocess" 7 | ) 8 | 9 | type IntraprocessService struct { 10 | orders chan <- intraprocess.OrderToProcess 11 | } 12 | 13 | func NewIntraprocessService(ordersChannel chan <- intraprocess.OrderToProcess) IntraprocessService { 14 | return IntraprocessService{ordersChannel} 15 | } 16 | 17 | func (i IntraprocessService) InitializeOrderPayment(id orders.ID, price price.Price) error { 18 | i.orders <- intraprocess.OrderToProcess{string(id), price} 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/product.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | ) 8 | 9 | type ProductID string 10 | 11 | var ErrEmptyProductID = errors.New("empty product ID") 12 | 13 | type Product struct { 14 | id ProductID 15 | name string 16 | price price.Price 17 | } 18 | 19 | func NewProduct(id ProductID, name string, price price.Price) (Product, error) { 20 | if len(id) == 0 { 21 | return Product{}, ErrEmptyProductID 22 | } 23 | 24 | return Product{id, name, price}, nil 25 | } 26 | 27 | func (p Product) ID() ProductID { 28 | return p.id 29 | } 30 | 31 | func (p Product) Name() string { 32 | return p.name 33 | } 34 | 35 | func (p Product) Price() price.Price { 36 | return p.price 37 | } 38 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/order.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import "errors" 4 | 5 | type ID string 6 | 7 | var ErrEmptyOrderID = errors.New("empty order id") 8 | 9 | type Order struct { 10 | id ID 11 | product Product 12 | address Address 13 | 14 | paid bool 15 | } 16 | 17 | func (o *Order) ID() ID { 18 | return o.id 19 | } 20 | 21 | func (o Order) Product() Product { 22 | return o.product 23 | } 24 | 25 | func (o Order) Address() Address { 26 | return o.address 27 | } 28 | 29 | func (o Order) Paid() bool { 30 | return o.paid 31 | } 32 | 33 | func (o *Order) MarkAsPaid() { 34 | o.paid = true 35 | } 36 | 37 | func NewOrder(id ID, product Product, address Address) (*Order, error) { 38 | if len(id) == 0 { 39 | return nil, ErrEmptyOrderID 40 | } 41 | 42 | return &Order{id, product, address, false}, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/orders/memory.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 4 | 5 | type MemoryRepository struct { 6 | orders []orders.Order 7 | } 8 | 9 | func NewMemoryRepository() *MemoryRepository { 10 | return &MemoryRepository{[]orders.Order{}} 11 | } 12 | 13 | func (m *MemoryRepository) Save(orderToSave *orders.Order) error { 14 | for i, p := range m.orders { 15 | if p.ID() == orderToSave.ID() { 16 | m.orders[i] = *orderToSave 17 | return nil 18 | } 19 | } 20 | 21 | m.orders = append(m.orders, *orderToSave) 22 | return nil 23 | } 24 | 25 | func (m MemoryRepository) ByID(id orders.ID) (*orders.Order, error) { 26 | for _, p := range m.orders { 27 | if p.ID() == id { 28 | return &p, nil 29 | } 30 | } 31 | 32 | return nil, orders.ErrNotFound 33 | } 34 | -------------------------------------------------------------------------------- /pkg/common/price/price.go: -------------------------------------------------------------------------------- 1 | package price 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrPriceTooLow = errors.New("price must be greater than 0") 7 | ErrInvalidCurrency = errors.New("invalid currency") 8 | ) 9 | 10 | type Price struct { 11 | cents uint 12 | currency string 13 | } 14 | 15 | func NewPrice(cents uint, currency string) (Price, error) { 16 | if cents <= 0 { 17 | return Price{}, ErrPriceTooLow 18 | } 19 | if len(currency) != 3 { 20 | return Price{}, ErrInvalidCurrency 21 | } 22 | 23 | return Price{cents, currency}, nil 24 | } 25 | 26 | // NewPriceP works as NewPrice, but on error it will panic instead of returning error. 27 | func NewPriceP(cents uint, currency string) (Price) { 28 | p, err := NewPrice(cents, currency) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | return p 34 | } 35 | 36 | func (p Price) Cents() uint { 37 | return p.cents 38 | } 39 | 40 | func (p Price) Currency() string { 41 | return p.currency 42 | } 43 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/shop/intraprocess_test.go: -------------------------------------------------------------------------------- 1 | package shop_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/shop" 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/private/intraprocess" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestOrderProductFromShopProduct(t *testing.T) { 14 | shopProduct := intraprocess.Product{ 15 | "123", 16 | "name", 17 | "desc", 18 | price.NewPriceP(42, "EUR"), 19 | } 20 | orderProduct, err := shop.OrderProductFromIntraprocess(shopProduct) 21 | assert.NoError(t, err) 22 | 23 | expectedOrderProduct, err := orders.NewProduct("123", "name", price.NewPriceP(42, "EUR")) 24 | assert.NoError(t, err) 25 | 26 | assert.EqualValues(t, expectedOrderProduct, orderProduct) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/common/http/error.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/render" 7 | ) 8 | 9 | type ErrResponse struct { 10 | Err error `json:"-"` // low-level runtime error 11 | HTTPStatusCode int `json:"-"` // http response status code 12 | 13 | AppCode int64 `json:"code,omitempty"` // application-specific error code 14 | ErrorText string `json:"error,omitempty"` // application-level error message 15 | } 16 | 17 | func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { 18 | render.Status(r, e.HTTPStatusCode) 19 | return nil 20 | } 21 | 22 | func ErrInternal(err error) render.Renderer { 23 | return &ErrResponse{ 24 | Err: err, 25 | HTTPStatusCode: http.StatusInternalServerError, 26 | ErrorText: err.Error(), 27 | } 28 | } 29 | 30 | func ErrBadRequest(err error) render.Renderer { 31 | return &ErrResponse{ 32 | Err: err, 33 | HTTPStatusCode: http.StatusBadRequest, 34 | ErrorText: err.Error(), 35 | } 36 | } -------------------------------------------------------------------------------- /pkg/shop/infrastructure/products/memory.go: -------------------------------------------------------------------------------- 1 | package products 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 5 | ) 6 | 7 | type MemoryRepository struct { 8 | products []products.Product 9 | } 10 | 11 | func NewMemoryRepository() *MemoryRepository { 12 | return &MemoryRepository{[]products.Product{}} 13 | } 14 | 15 | func (m *MemoryRepository) Save(productToSave *products.Product) error { 16 | for i, p := range m.products { 17 | if p.ID() == productToSave.ID() { 18 | m.products[i] = *productToSave 19 | return nil 20 | } 21 | } 22 | 23 | m.products = append(m.products, *productToSave) 24 | return nil 25 | } 26 | 27 | func (m MemoryRepository) ByID(id products.ID) (*products.Product, error) { 28 | for _, p := range m.products { 29 | if p.ID() == id { 30 | return &p, nil 31 | } 32 | } 33 | 34 | return nil, products.ErrNotFound 35 | } 36 | 37 | func (m MemoryRepository) AllProducts() ([]products.Product, error) { 38 | return m.products, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/shop/domain/products/products.go: -------------------------------------------------------------------------------- 1 | package products 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 5 | "errors" 6 | ) 7 | 8 | type ID string 9 | 10 | var ( 11 | ErrEmptyID = errors.New("empty product ID") 12 | ErrEmptyName = errors.New("empty product name") 13 | ) 14 | 15 | type Product struct { 16 | id ID 17 | 18 | name string 19 | description string 20 | 21 | price price.Price 22 | } 23 | 24 | func NewProduct(id ID, name string, description string, price price.Price) (*Product, error) { 25 | if len(id) == 0 { 26 | return nil, ErrEmptyID 27 | } 28 | if len(name) == 0 { 29 | return nil, ErrEmptyName 30 | } 31 | 32 | return &Product{id, name, description, price}, nil 33 | } 34 | 35 | func (p Product) ID() ID { 36 | return p.id 37 | } 38 | 39 | func (p Product) Name() string { 40 | return p.name 41 | } 42 | 43 | func (p Product) Description() string { 44 | return p.description 45 | } 46 | 47 | func (p Product) Price() price.Price { 48 | return p.price 49 | } 50 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/shop/intraprocess.go: -------------------------------------------------------------------------------- 1 | package shop 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 5 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/private/intraprocess" 6 | ) 7 | 8 | type IntraprocessService struct { 9 | intraprocessInterface intraprocess.ProductInterface 10 | } 11 | 12 | func NewIntraprocessService(intraprocessInterface intraprocess.ProductInterface) IntraprocessService { 13 | return IntraprocessService{intraprocessInterface} 14 | } 15 | 16 | func (i IntraprocessService) ProductByID(id orders.ProductID) (orders.Product, error) { 17 | shopProduct, err := i.intraprocessInterface.ProductByID(string(id)) 18 | if err != nil { 19 | return orders.Product{}, err 20 | } 21 | 22 | return OrderProductFromIntraprocess(shopProduct) 23 | } 24 | 25 | func OrderProductFromIntraprocess(shopProduct intraprocess.Product) (orders.Product, error) { 26 | return orders.NewProduct(orders.ProductID(shopProduct.ID), shopProduct.Name, shopProduct.Price) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/orders/interfaces/private/http/orders.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | common_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/http" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/application" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/render" 11 | ) 12 | 13 | func AddRoutes(router *chi.Mux, service application.OrdersService, repository orders.Repository) { 14 | resource := ordersResource{service, repository} 15 | router.Post("/orders/{id}/paid", resource.PostPaid) 16 | } 17 | 18 | type ordersResource struct { 19 | service application.OrdersService 20 | 21 | repository orders.Repository 22 | } 23 | 24 | func (o ordersResource) PostPaid(w http.ResponseWriter, r *http.Request) { 25 | cmd := application.MarkOrderAsPaidCommand{ 26 | OrderID: orders.ID(chi.URLParam(r, "id")), 27 | } 28 | 29 | if err := o.service.MarkOrderAsPaid(cmd); err != nil { 30 | _ = render.Render(w, r, common_http.ErrInternal(err)) 31 | return 32 | } 33 | 34 | w.WriteHeader(http.StatusNoContent) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/common/price/price_test.go: -------------------------------------------------------------------------------- 1 | package price_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewPrice(t *testing.T) { 11 | testCases := []struct { 12 | Name string 13 | Cents uint 14 | Currency string 15 | ExpectedErr error 16 | }{ 17 | { 18 | Name: "valid", 19 | Cents: 10, 20 | Currency: "EUR", 21 | }, 22 | { 23 | Name: "invalid_cents", 24 | Cents: 0, 25 | Currency: "EUR", 26 | ExpectedErr: price.ErrPriceTooLow, 27 | }, 28 | { 29 | Name: "empty_currency", 30 | Cents: 10, 31 | Currency: "", 32 | ExpectedErr: price.ErrInvalidCurrency, 33 | }, 34 | { 35 | Name: "invalid_currency_length", 36 | Cents: 10, 37 | Currency: "US", 38 | ExpectedErr: price.ErrInvalidCurrency, 39 | }, 40 | } 41 | 42 | for _, c := range testCases { 43 | t.Run(c.Name, func(t *testing.T) { 44 | _, err := price.NewPrice(c.Cents, c.Currency) 45 | assert.EqualValues(t, c.ExpectedErr, err) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/shop/interfaces/private/intraprocess/products.go: -------------------------------------------------------------------------------- 1 | package intraprocess 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 5 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type Product struct { 10 | ID string 11 | Name string 12 | Description string 13 | Price price.Price 14 | } 15 | 16 | func ProductFromDomainProduct(domainProduct products.Product) Product { 17 | return Product{ 18 | string(domainProduct.ID()), 19 | domainProduct.Name(), 20 | domainProduct.Description(), 21 | domainProduct.Price(), 22 | } 23 | } 24 | 25 | type ProductInterface struct { 26 | repo products.Repository 27 | } 28 | 29 | func NewProductInterface(repo products.Repository) ProductInterface { 30 | return ProductInterface{repo} 31 | } 32 | 33 | func (i ProductInterface) ProductByID(id string) (Product, error) { 34 | domainProduct, err := i.repo.ByID(products.ID(id)) 35 | if err != nil { 36 | return Product{}, errors.Wrap(err, "cannot get product") 37 | } 38 | 39 | return ProductFromDomainProduct(*domainProduct), nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/payments/application/payments.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 8 | ) 9 | 10 | type ordersService interface { 11 | MarkOrderAsPaid(orderID string) error 12 | } 13 | 14 | type PaymentsService struct { 15 | ordersService ordersService 16 | } 17 | 18 | func NewPaymentsService(ordersService ordersService) PaymentsService { 19 | return PaymentsService{ordersService} 20 | } 21 | 22 | func (s PaymentsService) InitializeOrderPayment(orderID string, price price.Price) error { 23 | // ... 24 | log.Printf("initializing payment for order %s", orderID) 25 | 26 | 27 | go func() { 28 | time.Sleep(time.Millisecond * 500) 29 | if err := s.PostOrderPayment(orderID); err != nil { 30 | log.Printf("cannot post order payment: %s", err) 31 | } 32 | }() 33 | 34 | // simulating payments provider delay 35 | //time.Sleep(time.Second) 36 | 37 | return nil 38 | } 39 | 40 | func (s PaymentsService) PostOrderPayment(orderID string) error { 41 | log.Printf("payment for order %s done, marking order as paid", orderID) 42 | 43 | return s.ordersService.MarkOrderAsPaid(orderID) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/address.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import "errors" 4 | 5 | type Address struct { 6 | name string 7 | street string 8 | city string 9 | postCode string 10 | country string 11 | } 12 | 13 | func NewAddress(name string, street string, city string, postCode string, country string) (Address, error) { 14 | if len(name) == 0 { 15 | return Address{}, errors.New("empty name") 16 | } 17 | if len(street) == 0 { 18 | return Address{}, errors.New("empty street") 19 | } 20 | if len(city) == 0 { 21 | return Address{}, errors.New("empty city") 22 | } 23 | if len(postCode) == 0 { 24 | return Address{}, errors.New("empty postCode") 25 | } 26 | if len(country) == 0 { 27 | return Address{}, errors.New("empty country") 28 | } 29 | 30 | return Address{name, street, city, postCode, country}, nil 31 | } 32 | 33 | func (a Address) Name() string { 34 | return a.name 35 | } 36 | 37 | func (a Address) Street() string { 38 | return a.street 39 | } 40 | 41 | func (a Address) City() string { 42 | return a.city 43 | } 44 | 45 | func (a Address) PostCode() string { 46 | return a.postCode 47 | } 48 | 49 | func (a Address) Country() string { 50 | return a.country 51 | } 52 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/address_test.go: -------------------------------------------------------------------------------- 1 | package orders_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewAddress(t *testing.T) { 11 | testCases := []struct { 12 | TestName string 13 | 14 | Name string 15 | Street string 16 | City string 17 | PostCode string 18 | Country string 19 | 20 | ExpectedErr bool 21 | }{ 22 | { 23 | TestName: "valid", 24 | Name: "test", 25 | Street: "test", 26 | City: "test", 27 | PostCode: "test", 28 | Country: "test", 29 | ExpectedErr: false, 30 | }, 31 | } 32 | 33 | for _, c := range testCases { 34 | t.Run(c.TestName, func(t *testing.T) { 35 | address, err := orders.NewAddress(c.Name, c.Street, c.City, c.PostCode, c.Country) 36 | 37 | if c.ExpectedErr { 38 | assert.Error(t, err) 39 | } else { 40 | assert.NoError(t, err) 41 | assert.EqualValues(t, c.Name, address.Name()) 42 | assert.EqualValues(t, c.Street, address.Street()) 43 | assert.EqualValues(t, c.City, address.City()) 44 | assert.EqualValues(t, c.PostCode, address.PostCode()) 45 | assert.EqualValues(t, c.Country, address.Country()) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/microservices/payments/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/cmd" 9 | payments_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/application" 10 | payments_infra_orders "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/infrastructure/orders" 11 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/interfaces/amqp" 12 | ) 13 | 14 | func main() { 15 | log.Println("Starting payments microservice") 16 | defer log.Println("Closing payments microservice") 17 | 18 | ctx := cmd.Context() 19 | 20 | paymentsInterface := createPaymentsMicroservice() 21 | if err := paymentsInterface.Run(ctx); err != nil { 22 | panic(err) 23 | } 24 | } 25 | 26 | func createPaymentsMicroservice() amqp.PaymentsInterface { 27 | cmd.WaitForService(os.Getenv("SHOP_RABBITMQ_ADDR")) 28 | 29 | paymentsService := payments_app.NewPaymentsService( 30 | payments_infra_orders.NewHTTPClient(os.Getenv("SHOP_ORDERS_SERVICE_ADDR")), 31 | ) 32 | 33 | paymentsInterface, err := amqp.NewPaymentsInterface( 34 | fmt.Sprintf("amqp://%s/", os.Getenv("SHOP_RABBITMQ_ADDR")), 35 | os.Getenv("SHOP_RABBITMQ_ORDERS_TO_PAY_QUEUE"), 36 | paymentsService, 37 | ) 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | return paymentsInterface 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Monolith Shop 2 | 3 | Source code for https://threedots.tech/post/microservices-or-monolith-its-detail/ article. 4 | 5 | This shop can work both as monolith and microservices. More info you will find in the article. 6 | 7 | This repository contains only REST API. 8 | 9 | ## Prerequisites 10 | 11 | You need **Docker** and **docker-compose** installed. 12 | 13 | Everything is running in Docker container, so you don't need golang 14 | either any other lib. 15 | 16 | ## Running 17 | 18 | Just run 19 | 20 | make up 21 | 22 | It will build Docker image and run monolith and microservices version. 23 | 24 | ### Services addresses 25 | 26 | Monolith: http://localhost:8090/ 27 | 28 | --- 29 | 30 | Orders microservice: http://localhost:8070/ 31 | 32 | Shop microservice: http://localhost:8071/ 33 | 34 | Payments microservice: no public API (you can export ports in `docker-compose.yml` if you need) 35 | 36 | For available methods, please check interfaces layer in source code and tests `tests/acceptance_test.go`. 37 | 38 | ## Running tests 39 | 40 | First of all you must run services 41 | 42 | make up 43 | 44 | 45 | Then you can run all tests by using in another terminal: 46 | 47 | make docker-test 48 | 49 | 50 | If you want to test only monolith version: 51 | 52 | make docker-test-monolith 53 | 54 | or microservices: 55 | 56 | make docker-test-microservices 57 | -------------------------------------------------------------------------------- /pkg/payments/interfaces/intraprocess/orders.go: -------------------------------------------------------------------------------- 1 | package intraprocess 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/application" 9 | ) 10 | 11 | type OrderToProcess struct { 12 | ID string 13 | Price price.Price 14 | } 15 | 16 | type PaymentsInterface struct { 17 | orders <-chan OrderToProcess 18 | service application.PaymentsService 19 | orderProcessingWg *sync.WaitGroup 20 | runEnded chan struct{} 21 | } 22 | 23 | func NewPaymentsInterface(orders <-chan OrderToProcess, service application.PaymentsService) PaymentsInterface { 24 | return PaymentsInterface{ 25 | orders, 26 | service, 27 | &sync.WaitGroup{}, 28 | make(chan struct{}, 1), 29 | } 30 | } 31 | 32 | func (o PaymentsInterface) Run() { 33 | defer func() { 34 | o.runEnded <- struct{}{} 35 | }() 36 | 37 | for order := range o.orders { 38 | go func(orderToPay OrderToProcess) { 39 | o.orderProcessingWg.Add(1) 40 | defer o.orderProcessingWg.Done() 41 | 42 | if err := o.service.InitializeOrderPayment(orderToPay.ID, orderToPay.Price); err != nil { 43 | log.Print("Cannot initialize payment:", err) 44 | } 45 | }(order) 46 | } 47 | } 48 | 49 | func (o PaymentsInterface) Close() { 50 | o.orderProcessingWg.Wait() 51 | <-o.runEnded 52 | } 53 | -------------------------------------------------------------------------------- /pkg/shop/domain/products/products_test.go: -------------------------------------------------------------------------------- 1 | package products_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewProduct(t *testing.T) { 12 | testPrice, err := price.NewPrice(42, "USD") 13 | assert.NoError(t, err) 14 | 15 | testCases := []struct { 16 | TestName string 17 | 18 | ID products.ID 19 | Name string 20 | Description string 21 | Price price.Price 22 | 23 | ExpectedErr error 24 | }{ 25 | { 26 | TestName: "valid", 27 | ID: "1", 28 | Name: "foo", 29 | Description: "bar", 30 | Price: testPrice, 31 | }, 32 | { 33 | TestName: "empty_id", 34 | ID: "", 35 | Name: "foo", 36 | Description: "bar", 37 | Price: testPrice, 38 | 39 | ExpectedErr: products.ErrEmptyID, 40 | }, 41 | { 42 | TestName: "empty_name", 43 | ID: "1", 44 | Name: "", 45 | Description: "bar", 46 | Price: testPrice, 47 | 48 | ExpectedErr: products.ErrEmptyName, 49 | }, 50 | } 51 | 52 | for _, c := range testCases { 53 | t.Run(c.TestName, func(t *testing.T) { 54 | _, err := products.NewProduct(c.ID, c.Name, c.Description, c.Price) 55 | assert.EqualValues(t, c.ExpectedErr, err) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/shop/application/products.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 5 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | type productReadModel interface { 10 | AllProducts() ([]products.Product, error) 11 | } 12 | 13 | type ProductsService struct { 14 | repo products.Repository 15 | readModel productReadModel 16 | } 17 | 18 | func NewProductsService(repo products.Repository, readModel productReadModel) ProductsService { 19 | return ProductsService{repo, readModel} 20 | } 21 | 22 | func (s ProductsService) AllProducts() ([]products.Product, error) { 23 | return s.readModel.AllProducts() 24 | } 25 | 26 | type AddProductCommand struct { 27 | ID string 28 | Name string 29 | Description string 30 | PriceCents uint 31 | PriceCurrency string 32 | } 33 | 34 | func (s ProductsService) AddProduct(cmd AddProductCommand) error { 35 | price, err := price.NewPrice(cmd.PriceCents, cmd.PriceCurrency) 36 | if err != nil { 37 | return errors.Wrap(err, "invalid product price") 38 | } 39 | 40 | p, err := products.NewProduct(products.ID(cmd.ID), cmd.Name, cmd.Description, price) 41 | if err != nil { 42 | return errors.Wrap(err, "cannot create product") 43 | } 44 | 45 | if err := s.repo.Save(p); err != nil { 46 | return errors.Wrap(err, "cannot save product") 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/orders/memory_test.go: -------------------------------------------------------------------------------- 1 | package orders_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | order_domain "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/orders" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMemoryRepository(t *testing.T) { 13 | repo := orders.NewMemoryRepository() 14 | 15 | order1 := addOrder(t, repo, "1") 16 | // test idempotency 17 | _ = addOrder(t, repo, "1") 18 | 19 | repoOrder1, err := repo.ByID("1") 20 | assert.NoError(t, err) 21 | assert.EqualValues(t, *order1, *repoOrder1) 22 | 23 | order2 := addOrder(t, repo, "2") 24 | 25 | repoOrder2, err := repo.ByID("2") 26 | assert.NoError(t, err) 27 | assert.EqualValues(t, *order2, *repoOrder2) 28 | } 29 | 30 | 31 | func addOrder(t *testing.T, repo *orders.MemoryRepository, id string) *order_domain.Order { 32 | productPrice, err := price.NewPrice(10, "USD") 33 | assert.NoError(t, err) 34 | 35 | orderProduct, err := order_domain.NewProduct("1", "foo", productPrice) 36 | assert.NoError(t, err) 37 | 38 | orderAddress, err := order_domain.NewAddress("test", "test", "test", "test", "test") 39 | assert.NoError(t, err) 40 | 41 | p, err := order_domain.NewOrder(order_domain.ID(id), orderProduct, orderAddress) 42 | assert.NoError(t, err) 43 | 44 | err = repo.Save(p) 45 | assert.NoError(t, err) 46 | 47 | return p 48 | } 49 | -------------------------------------------------------------------------------- /pkg/shop/interfaces/private/http/products.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | common_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/http" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 8 | products_domain "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/render" 11 | ) 12 | 13 | func AddRoutes(router *chi.Mux, repo products_domain.Repository) { 14 | resource := productsResource{repo} 15 | router.Get("/products/{id}", resource.Get) 16 | } 17 | 18 | type productsResource struct { 19 | repo products_domain.Repository 20 | } 21 | 22 | type ProductView struct { 23 | ID string `json:"id"` 24 | 25 | Name string `json:"name"` 26 | Description string `json:"description"` 27 | 28 | Price PriceView `json:"price"` 29 | } 30 | 31 | type PriceView struct { 32 | Cents uint `json:"cents"` 33 | Currency string `json:"currency"` 34 | } 35 | 36 | func priceViewFromPrice(p price.Price) PriceView { 37 | return PriceView{p.Cents(), p.Currency()} 38 | } 39 | 40 | func (p productsResource) Get(w http.ResponseWriter, r *http.Request) { 41 | product, err := p.repo.ByID(products_domain.ID(chi.URLParam(r, "id"))) 42 | 43 | if err != nil { 44 | _ = render.Render(w, r, common_http.ErrInternal(err)) 45 | return 46 | } 47 | 48 | render.Respond(w, r, ProductView{ 49 | string(product.ID()), 50 | product.Name(), 51 | product.Description(), 52 | priceViewFromPrice(product.Price()), 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/orders/domain/orders/order_test.go: -------------------------------------------------------------------------------- 1 | package orders_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewOrder(t *testing.T) { 12 | orderProduct, orderAddress := createOrderContent(t) 13 | 14 | testOrder, err := orders.NewOrder("1", orderProduct, orderAddress) 15 | assert.NoError(t, err) 16 | 17 | assert.EqualValues(t, orderProduct, testOrder.Product()) 18 | assert.EqualValues(t, orderAddress, testOrder.Address()) 19 | assert.False(t, testOrder.Paid()) 20 | } 21 | 22 | func TestNewOrder_empty_id(t *testing.T) { 23 | orderProduct, orderAddress := createOrderContent(t) 24 | 25 | _, err := orders.NewOrder("", orderProduct, orderAddress) 26 | assert.EqualValues(t, orders.ErrEmptyOrderID, err) 27 | } 28 | 29 | func TestOrder_MarkAsPaid(t *testing.T) { 30 | orderProduct, orderAddress := createOrderContent(t) 31 | 32 | testOrder, err := orders.NewOrder("1", orderProduct, orderAddress) 33 | assert.NoError(t, err) 34 | 35 | assert.False(t, testOrder.Paid()) 36 | testOrder.MarkAsPaid() 37 | assert.True(t, testOrder.Paid()) 38 | } 39 | 40 | func createOrderContent(t *testing.T) (orders.Product, orders.Address) { 41 | productPrice, err := price.NewPrice(10, "USD") 42 | assert.NoError(t, err) 43 | 44 | orderProduct, err := orders.NewProduct("1", "foo", productPrice) 45 | assert.NoError(t, err) 46 | 47 | orderAddress, err := orders.NewAddress("test", "test", "test", "test", "test") 48 | assert.NoError(t, err) 49 | 50 | return orderProduct, orderAddress 51 | } 52 | -------------------------------------------------------------------------------- /cmd/microservices/shop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/cmd" 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop" 10 | shop_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/application" 11 | shop_infra_product "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/infrastructure/products" 12 | shop_interfaces_private_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/private/http" 13 | shop_interfaces_public_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/public/http" 14 | "github.com/go-chi/chi" 15 | ) 16 | 17 | func main() { 18 | log.Println("Starting shop microservice") 19 | 20 | ctx := cmd.Context() 21 | 22 | r := createShopMicroservice() 23 | server := &http.Server{Addr: os.Getenv("SHOP_SHOP_SERVICE_BIND_ADDR"), Handler: r} 24 | go func() { 25 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 26 | panic(err) 27 | } 28 | }() 29 | 30 | <-ctx.Done() 31 | log.Println("Closing shop microservice") 32 | 33 | if err := server.Close(); err != nil { 34 | panic(err) 35 | } 36 | } 37 | 38 | func createShopMicroservice() *chi.Mux { 39 | shopProductRepo := shop_infra_product.NewMemoryRepository() 40 | shopProductsService := shop_app.NewProductsService(shopProductRepo, shopProductRepo) 41 | 42 | if err := shop.LoadShopFixtures(shopProductsService); err != nil { 43 | panic(err) 44 | } 45 | 46 | r := cmd.CreateRouter() 47 | 48 | shop_interfaces_public_http.AddRoutes(r, shopProductRepo) 49 | shop_interfaces_private_http.AddRoutes(r, shopProductRepo) 50 | 51 | return r 52 | } 53 | -------------------------------------------------------------------------------- /pkg/shop/interfaces/public/http/products.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | common_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/http" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/render" 11 | ) 12 | 13 | func AddRoutes(router *chi.Mux, productsReadModel productsReadModel) { 14 | resource := productsResource{productsReadModel} 15 | router.Get("/products", resource.GetAll) 16 | } 17 | 18 | type productsReadModel interface { 19 | AllProducts() ([]products.Product, error) 20 | } 21 | 22 | type productView struct { 23 | ID string `json:"id"` 24 | 25 | Name string `json:"name"` 26 | Description string `json:"description"` 27 | 28 | Price priceView `json:"price"` 29 | } 30 | 31 | type priceView struct { 32 | Cents uint `json:"cents"` 33 | Currency string `json:"currency"` 34 | } 35 | 36 | func priceViewFromPrice(p price.Price) priceView { 37 | return priceView{p.Cents(), p.Currency()} 38 | } 39 | 40 | type productsResource struct { 41 | readModel productsReadModel 42 | } 43 | 44 | func (p productsResource) GetAll(w http.ResponseWriter, r *http.Request) { 45 | products, err := p.readModel.AllProducts() 46 | if err != nil { 47 | _ = render.Render(w, r, common_http.ErrInternal(err)) 48 | return 49 | } 50 | 51 | view := []productView{} 52 | for _, product := range products { 53 | view = append(view, productView{ 54 | string(product.ID()), 55 | product.Name(), 56 | product.Description(), 57 | priceViewFromPrice(product.Price()), 58 | }) 59 | } 60 | 61 | render.Respond(w, r, view) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/shop/infrastructure/products/memory_test.go: -------------------------------------------------------------------------------- 1 | package products_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | products_domain "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/domain/products" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/infrastructure/products" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMemoryRepository(t *testing.T) { 13 | repo := products.NewMemoryRepository() 14 | 15 | assertAllProducts(t, repo, []products_domain.Product{}) 16 | 17 | product1 := addProduct(t, repo, "1") 18 | // test idempotency 19 | _ = addProduct(t, repo, "1") 20 | 21 | assertAllProducts(t, repo, []products_domain.Product{*product1}) 22 | repoProduct1, err := repo.ByID("1") 23 | assert.NoError(t, err) 24 | assert.EqualValues(t, *product1, *repoProduct1) 25 | 26 | product2 := addProduct(t, repo, "2") 27 | 28 | assertAllProducts(t, repo, []products_domain.Product{*product1, *product2}) 29 | repoProduct2, err := repo.ByID("2") 30 | assert.NoError(t, err) 31 | assert.EqualValues(t, *product2, *repoProduct2) 32 | } 33 | 34 | func assertAllProducts(t *testing.T, repo *products.MemoryRepository, expectedProducts []products_domain.Product) { 35 | products, err := repo.AllProducts() 36 | 37 | assert.EqualValues(t, expectedProducts, products) 38 | assert.NoError(t, err) 39 | } 40 | 41 | func addProduct(t *testing.T, repo *products.MemoryRepository, id string) *products_domain.Product { 42 | price, err := price.NewPrice(42, "USD") 43 | assert.NoError(t, err) 44 | 45 | p, err := products_domain.NewProduct(products_domain.ID(id), "foo " + id, "bar " + id, price) 46 | assert.NoError(t, err) 47 | 48 | err = repo.Save(p) 49 | assert.NoError(t, err) 50 | 51 | return p 52 | } 53 | -------------------------------------------------------------------------------- /pkg/orders/infrastructure/shop/http.go: -------------------------------------------------------------------------------- 1 | package shop 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 10 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 11 | http_interface "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/private/http" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type HTTPClient struct { 16 | address string 17 | } 18 | 19 | func NewHTTPClient(address string) HTTPClient { 20 | return HTTPClient{address} 21 | } 22 | 23 | func (h HTTPClient) ProductByID(id orders.ProductID) (orders.Product, error) { 24 | resp, err := http.Get(fmt.Sprintf("%s/products/%s", h.address, id)) 25 | if err != nil { 26 | return orders.Product{}, errors.Wrap(err, "request to shop failed") 27 | } 28 | 29 | defer func() { 30 | _ = resp.Body.Close() 31 | }() 32 | b, err := ioutil.ReadAll(resp.Body) 33 | if err != nil { 34 | return orders.Product{}, errors.Wrap(err, "cannot read response") 35 | } 36 | 37 | productView := http_interface.ProductView{} 38 | if err := json.Unmarshal(b, &productView); err != nil { 39 | return orders.Product{}, errors.Wrapf(err, "cannot decode response: %s", b) 40 | } 41 | 42 | return OrderProductFromHTTP(productView) 43 | } 44 | 45 | 46 | func OrderProductFromHTTP(shopProduct http_interface.ProductView) (orders.Product, error) { 47 | productPrice, err := OrderProductPriceFromHTTP(shopProduct.Price) 48 | if err != nil { 49 | return orders.Product{}, errors.Wrap(err, "cannot decode price") 50 | } 51 | 52 | return orders.NewProduct(orders.ProductID(shopProduct.ID), shopProduct.Name, productPrice) 53 | } 54 | 55 | func OrderProductPriceFromHTTP(priceView http_interface.PriceView) (price.Price, error) { 56 | return price.NewPrice(priceView.Cents, priceView.Currency) 57 | } -------------------------------------------------------------------------------- /pkg/orders/infrastructure/payments/amqp.go: -------------------------------------------------------------------------------- 1 | package payments 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 9 | payments_amqp_interface "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/interfaces/amqp" 10 | "github.com/pkg/errors" 11 | "github.com/streadway/amqp" 12 | ) 13 | 14 | type AMQPService struct { 15 | queue amqp.Queue 16 | channel *amqp.Channel 17 | } 18 | 19 | func NewAMQPService(url, queueName string) (AMQPService, error) { 20 | conn, err := amqp.Dial(url) 21 | if err != nil { 22 | return AMQPService{}, err 23 | } 24 | 25 | ch, err := conn.Channel() 26 | if err != nil { 27 | return AMQPService{}, err 28 | } 29 | 30 | q, err := ch.QueueDeclare( 31 | queueName, 32 | true, 33 | false, 34 | false, 35 | false, 36 | nil, 37 | ) 38 | if err != nil { 39 | return AMQPService{}, err 40 | } 41 | 42 | return AMQPService{q, ch}, nil 43 | } 44 | 45 | func (i AMQPService) InitializeOrderPayment(id orders.ID, price price.Price) error { 46 | order := payments_amqp_interface.OrderToProcessView{ 47 | ID: string(id), 48 | Price: payments_amqp_interface.PriceView{ 49 | Cents: price.Cents(), 50 | Currency: price.Currency(), 51 | }, 52 | } 53 | 54 | b, err := json.Marshal(order) 55 | if err != nil { 56 | return errors.Wrap(err, "cannot marshal order for amqp") 57 | } 58 | 59 | err = i.channel.Publish( 60 | "", 61 | i.queue.Name, 62 | false, 63 | false, 64 | amqp.Publishing{ 65 | ContentType: "application/json", 66 | Body: b, 67 | }) 68 | if err != nil { 69 | return errors.Wrap(err, "cannot send order to amqp") 70 | } 71 | 72 | log.Printf("sent order %s to amqp", id) 73 | 74 | return nil 75 | } 76 | 77 | func (i AMQPService) Close() error { 78 | return i.channel.Close() 79 | } 80 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | monolith: 4 | build: . 5 | entrypoint: ["./docker/entrypoint.sh", "./cmd/monolith/main.go"] 6 | ports: 7 | - "8090:8080" 8 | environment: 9 | - SHOP_MONOLITH_BIND_ADDR=:8080 10 | volumes: 11 | - ./cmd:$GO_PROJECT_DIR/cmd 12 | - ./pkg:$GO_PROJECT_DIR/pkg 13 | 14 | orders: 15 | build: . 16 | entrypoint: ["./docker/entrypoint.sh", "./cmd/microservices/orders/main.go"] 17 | ports: 18 | - "8070:8080" 19 | environment: 20 | - SHOP_ORDERS_SERVICE_BIND_ADDR=:8080 21 | - SHOP_RABBITMQ_ADDR=rabbitmq:5672 22 | - SHOP_RABBITMQ_ORDERS_TO_PAY_QUEUE=orders-to-pay 23 | - SHOP_SHOP_SERVICE_ADDR=http://shop:8080 24 | volumes: 25 | - ./cmd:$GO_PROJECT_DIR/cmd 26 | - ./pkg:$GO_PROJECT_DIR/pkg 27 | depends_on: 28 | - rabbitmq 29 | 30 | payments: 31 | build: . 32 | entrypoint: ["./docker/entrypoint.sh", "./cmd/microservices/payments/main.go"] 33 | volumes: 34 | - ./cmd:$GO_PROJECT_DIR/cmd 35 | - ./pkg:$GO_PROJECT_DIR/pkg 36 | environment: 37 | - SHOP_RABBITMQ_ADDR=rabbitmq:5672 38 | - SHOP_RABBITMQ_ORDERS_TO_PAY_QUEUE=orders-to-pay 39 | - SHOP_ORDERS_SERVICE_ADDR=http://orders:8080 40 | depends_on: 41 | - rabbitmq 42 | 43 | shop: 44 | build: . 45 | entrypoint: ["./docker/entrypoint.sh", "./cmd/microservices/shop/main.go"] 46 | volumes: 47 | - ./cmd:$GO_PROJECT_DIR/cmd 48 | - ./pkg:$GO_PROJECT_DIR/pkg 49 | environment: 50 | - SHOP_SHOP_SERVICE_BIND_ADDR=:8080 51 | ports: 52 | - "8071:8080" 53 | depends_on: 54 | - rabbitmq 55 | 56 | rabbitmq: 57 | image: rabbitmq:3.7-management 58 | ports: 59 | - "15672:15672" 60 | 61 | tests: 62 | build: . 63 | entrypoint: ["sleep", "infinity"] 64 | depends_on: 65 | - shop 66 | - payments 67 | - orders 68 | - monolith 69 | volumes: 70 | - ./tests:$GO_PROJECT_DIR/tests 71 | -------------------------------------------------------------------------------- /cmd/microservices/orders/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/cmd" 10 | orders_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/application" 11 | orders_infra_orders "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/orders" 12 | orders_infra_payments "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/payments" 13 | orders_infra_product "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/shop" 14 | orders_private_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/private/http" 15 | orders_public_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/public/http" 16 | "github.com/go-chi/chi" 17 | ) 18 | 19 | func main() { 20 | log.Println("Starting orders microservice") 21 | 22 | ctx := cmd.Context() 23 | 24 | r, closeFn := createOrdersMicroservice() 25 | defer closeFn() 26 | 27 | server := &http.Server{Addr: os.Getenv("SHOP_ORDERS_SERVICE_BIND_ADDR"), Handler: r} 28 | go func() { 29 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 30 | panic(err) 31 | } 32 | }() 33 | 34 | <-ctx.Done() 35 | log.Println("Closing orders microservice") 36 | 37 | if err := server.Close(); err != nil { 38 | panic(err) 39 | } 40 | } 41 | 42 | func createOrdersMicroservice() (router *chi.Mux, closeFn func()) { 43 | cmd.WaitForService(os.Getenv("SHOP_RABBITMQ_ADDR")) 44 | 45 | shopHTTPClient := orders_infra_product.NewHTTPClient(os.Getenv("SHOP_SHOP_SERVICE_ADDR")) 46 | 47 | ordersToPayQueue, err := orders_infra_payments.NewAMQPService( 48 | fmt.Sprintf("amqp://%s/", os.Getenv("SHOP_RABBITMQ_ADDR")), 49 | os.Getenv("SHOP_RABBITMQ_ORDERS_TO_PAY_QUEUE"), 50 | ) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | ordersRepo := orders_infra_orders.NewMemoryRepository() 56 | ordersService := orders_app.NewOrdersService( 57 | shopHTTPClient, 58 | ordersToPayQueue, 59 | ordersRepo, 60 | ) 61 | 62 | r := cmd.CreateRouter() 63 | 64 | orders_public_http.AddRoutes(r, ordersService, ordersRepo) 65 | orders_private_http.AddRoutes(r, ordersService, ordersRepo) 66 | 67 | return r, func() { 68 | err := ordersToPayQueue.Close() 69 | if err != nil { 70 | log.Printf("cannot close orders queue: %s", err) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/orders/interfaces/public/http/orders.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | common_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/http" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/application" 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/render" 11 | "github.com/satori/go.uuid" 12 | ) 13 | 14 | func AddRoutes(router *chi.Mux, service application.OrdersService, repository orders.Repository) { 15 | resource := ordersResource{service, repository} 16 | router.Post("/orders", resource.Post) 17 | router.Get("/orders/{id}/paid", resource.GetPaid) 18 | } 19 | 20 | type ordersResource struct { 21 | service application.OrdersService 22 | 23 | repository orders.Repository 24 | } 25 | 26 | func (o ordersResource) Post(w http.ResponseWriter, r *http.Request) { 27 | req := PostOrderRequest{} 28 | if err := render.Decode(r, &req); err != nil { 29 | _ = render.Render(w, r, common_http.ErrBadRequest(err)) 30 | return 31 | } 32 | 33 | cmd := application.PlaceOrderCommand{ 34 | OrderID: orders.ID(uuid.NewV1().String()), 35 | ProductID: req.ProductID, 36 | Address: application.PlaceOrderCommandAddress(req.Address), 37 | } 38 | if err := o.service.PlaceOrder(cmd); err != nil { 39 | _ = render.Render(w, r, common_http.ErrInternal(err)) 40 | return 41 | } 42 | 43 | w.WriteHeader(http.StatusOK) 44 | render.JSON(w, r, PostOrdersResponse{ 45 | OrderID: string(cmd.OrderID), 46 | }) 47 | } 48 | 49 | type PostOrderAddress struct { 50 | Name string `json:"name"` 51 | Street string `json:"street"` 52 | City string `json:"city"` 53 | PostCode string `json:"post_code"` 54 | Country string `json:"country"` 55 | } 56 | 57 | type PostOrderRequest struct { 58 | ProductID orders.ProductID `json:"product_id"` 59 | Address PostOrderAddress `json:"address"` 60 | } 61 | 62 | type PostOrdersResponse struct { 63 | OrderID string 64 | } 65 | 66 | type OrderPaidView struct { 67 | ID string `json:"id"` 68 | IsPaid bool `json:"is_paid"` 69 | } 70 | 71 | func (o ordersResource) GetPaid(w http.ResponseWriter, r *http.Request) { 72 | order, err := o.repository.ByID(orders.ID(chi.URLParam(r, "id"))) 73 | if err != nil { 74 | _ = render.Render(w, r, common_http.ErrBadRequest(err)) 75 | return 76 | } 77 | 78 | render.Respond(w, r, OrderPaidView{string(order.ID()), order.Paid()}) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/payments/interfaces/amqp/orders.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | 8 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/application" 10 | "github.com/streadway/amqp" 11 | ) 12 | 13 | type OrderToProcessView struct { 14 | ID string `json:"id"` 15 | Price PriceView 16 | } 17 | 18 | type PriceView struct { 19 | Cents uint `json:"cents"` 20 | Currency string `json:"currency"` 21 | } 22 | 23 | type PaymentsInterface struct { 24 | conn *amqp.Connection 25 | queue amqp.Queue 26 | channel *amqp.Channel 27 | 28 | service application.PaymentsService 29 | } 30 | 31 | func NewPaymentsInterface(url string, queueName string, service application.PaymentsService) (PaymentsInterface, error) { 32 | conn, err := amqp.Dial(url) 33 | if err != nil { 34 | return PaymentsInterface{}, err 35 | } 36 | 37 | ch, err := conn.Channel() 38 | if err != nil { 39 | return PaymentsInterface{}, err 40 | } 41 | 42 | q, err := ch.QueueDeclare( 43 | queueName, 44 | true, 45 | false, 46 | false, 47 | false, 48 | nil, 49 | ) 50 | if err != nil { 51 | return PaymentsInterface{}, err 52 | } 53 | 54 | return PaymentsInterface{conn, q, ch, service}, nil 55 | } 56 | 57 | func (o PaymentsInterface) Run(ctx context.Context) error { 58 | msgs, err := o.channel.Consume( 59 | o.queue.Name, 60 | "", 61 | true, 62 | false, 63 | false, 64 | false, 65 | nil, 66 | ) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | done := ctx.Done() 72 | defer func() { 73 | if err := o.conn.Close(); err != nil { 74 | log.Print("cannot close conn: ", err) 75 | } 76 | if err := o.channel.Close(); err != nil { 77 | log.Print("cannot close channel: ", err) 78 | } 79 | }() 80 | 81 | for { 82 | select { 83 | case msg := <-msgs: 84 | err := o.processMsg(msg) 85 | if err != nil { 86 | log.Printf("cannot process msg: %s, err: %s", msg.Body, err) 87 | } 88 | case <-done: 89 | return nil 90 | } 91 | } 92 | } 93 | 94 | func (o PaymentsInterface) processMsg(msg amqp.Delivery) error { 95 | orderView := OrderToProcessView{} 96 | err := json.Unmarshal(msg.Body, &orderView) 97 | if err != nil { 98 | log.Printf("cannot decode msg: %s, error: %s", string(msg.Body), err) 99 | } 100 | 101 | orderPrice, err := price.NewPrice(orderView.Price.Cents, orderView.Price.Currency) 102 | if err != nil { 103 | log.Printf("cannot decode price for msg %s: %s", string(msg.Body), err) 104 | 105 | } 106 | 107 | return o.service.InitializeOrderPayment(orderView.ID, orderPrice) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/orders/application/orders.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/price" 7 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type productsService interface { 12 | ProductByID(id orders.ProductID) (orders.Product, error) 13 | } 14 | 15 | type paymentsService interface { 16 | InitializeOrderPayment(id orders.ID, price price.Price) error 17 | } 18 | 19 | type OrdersService struct { 20 | productsService productsService 21 | paymentsService paymentsService 22 | 23 | ordersRepository orders.Repository 24 | } 25 | 26 | func NewOrdersService(productsService productsService, paymentsService paymentsService, ordersRepository orders.Repository) OrdersService { 27 | return OrdersService{productsService, paymentsService, ordersRepository} 28 | } 29 | 30 | type PlaceOrderCommandAddress struct { 31 | Name string 32 | Street string 33 | City string 34 | PostCode string 35 | Country string 36 | } 37 | 38 | type PlaceOrderCommand struct { 39 | OrderID orders.ID 40 | ProductID orders.ProductID 41 | 42 | Address PlaceOrderCommandAddress 43 | } 44 | 45 | func (s OrdersService) PlaceOrder(cmd PlaceOrderCommand) error { 46 | address, err := orders.NewAddress( 47 | cmd.Address.Name, 48 | cmd.Address.Street, 49 | cmd.Address.City, 50 | cmd.Address.PostCode, 51 | cmd.Address.Country, 52 | ) 53 | if err != nil { 54 | return errors.Wrap(err, "invalid address") 55 | } 56 | 57 | product, err := s.productsService.ProductByID(cmd.ProductID) 58 | if err != nil { 59 | return errors.Wrap(err, "cannot get product") 60 | } 61 | 62 | newOrder, err := orders.NewOrder(cmd.OrderID, product, address) 63 | if err != nil { 64 | return errors.Wrap(err, "cannot create order") 65 | } 66 | 67 | if err := s.ordersRepository.Save(newOrder); err != nil { 68 | return errors.Wrap(err, "cannot save order") 69 | } 70 | 71 | if err := s.paymentsService.InitializeOrderPayment(newOrder.ID(), newOrder.Product().Price()); err != nil { 72 | return errors.Wrap(err, "cannot initialize payment") 73 | } 74 | 75 | log.Printf("order %s placed", cmd.OrderID) 76 | 77 | return nil 78 | } 79 | 80 | type MarkOrderAsPaidCommand struct { 81 | OrderID orders.ID 82 | } 83 | 84 | func (s OrdersService) MarkOrderAsPaid(cmd MarkOrderAsPaidCommand) error { 85 | o, err := s.ordersRepository.ByID(cmd.OrderID) 86 | if err != nil { 87 | return errors.Wrapf(err, "cannot get order %s", cmd.OrderID) 88 | } 89 | 90 | o.MarkAsPaid() 91 | 92 | if err := s.ordersRepository.Save(o); err != nil { 93 | return errors.Wrap(err, "cannot save order") 94 | } 95 | 96 | log.Printf("marked order %s as paid", cmd.OrderID) 97 | 98 | return nil 99 | } 100 | 101 | func (s OrdersService) OrderByID(id orders.ID) (orders.Order, error) { 102 | o, err := s.ordersRepository.ByID(id) 103 | if err != nil { 104 | return orders.Order{}, errors.Wrapf(err, "cannot get order %s", id) 105 | } 106 | 107 | return *o, nil 108 | } 109 | -------------------------------------------------------------------------------- /cmd/monolith/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // yep, it's a bit ugly :( 4 | import ( 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/common/cmd" 10 | orders_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/application" 11 | orders_infra_orders "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/orders" 12 | orders_infra_payments "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/payments" 13 | orders_infra_product "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/infrastructure/shop" 14 | orders_interfaces_intraprocess "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/private/intraprocess" 15 | orders_interfaces_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/public/http" 16 | payments_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/application" 17 | payments_infra_orders "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/infrastructure/orders" 18 | payments_interfaces_intraprocess "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/payments/interfaces/intraprocess" 19 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop" 20 | shop_app "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/application" 21 | shop_infra_product "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/infrastructure/products" 22 | shop_interfaces_intraprocess "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/private/intraprocess" 23 | shop_interfaces_http "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/shop/interfaces/public/http" 24 | "github.com/go-chi/chi" 25 | ) 26 | 27 | func main() { 28 | log.Println("Starting monolith") 29 | ctx := cmd.Context() 30 | 31 | ordersToPay := make(chan payments_interfaces_intraprocess.OrderToProcess) 32 | router, paymentsInterface := createMonolith(ordersToPay) 33 | go paymentsInterface.Run() 34 | 35 | server := &http.Server{Addr: os.Getenv("SHOP_MONOLITH_BIND_ADDR"), Handler: router} 36 | go func() { 37 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 38 | panic(err) 39 | } 40 | }() 41 | log.Printf("Monolith is listening on %s", server.Addr) 42 | 43 | <-ctx.Done() 44 | log.Println("Closing monolith") 45 | 46 | if err := server.Close(); err != nil { 47 | panic(err) 48 | } 49 | 50 | close(ordersToPay) 51 | paymentsInterface.Close() 52 | } 53 | 54 | func createMonolith(ordersToPay chan payments_interfaces_intraprocess.OrderToProcess) (*chi.Mux, payments_interfaces_intraprocess.PaymentsInterface) { 55 | shopProductRepo := shop_infra_product.NewMemoryRepository() 56 | shopProductsService := shop_app.NewProductsService(shopProductRepo, shopProductRepo) 57 | shopProductIntraprocessInterface := shop_interfaces_intraprocess.NewProductInterface(shopProductRepo) 58 | 59 | ordersRepo := orders_infra_orders.NewMemoryRepository() 60 | orderService := orders_app.NewOrdersService( 61 | orders_infra_product.NewIntraprocessService(shopProductIntraprocessInterface), 62 | orders_infra_payments.NewIntraprocessService(ordersToPay), 63 | ordersRepo, 64 | ) 65 | ordersIntraprocessInterface := orders_interfaces_intraprocess.NewOrdersInterface(orderService) 66 | 67 | paymentsService := payments_app.NewPaymentsService( 68 | payments_infra_orders.NewIntraprocessService(ordersIntraprocessInterface), 69 | ) 70 | paymentsIntraprocessInterface := payments_interfaces_intraprocess.NewPaymentsInterface(ordersToPay, paymentsService) 71 | 72 | if err := shop.LoadShopFixtures(shopProductsService); err != nil { 73 | panic(err) 74 | } 75 | 76 | r := cmd.CreateRouter() 77 | shop_interfaces_http.AddRoutes(r, shopProductRepo) 78 | orders_interfaces_http.AddRoutes(r, orderService, ordersRepo) 79 | 80 | return r, paymentsIntraprocessInterface 81 | } 82 | -------------------------------------------------------------------------------- /tests/acceptance_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "testing" 10 | "time" 11 | 12 | "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/domain/orders" 13 | orders_http_interface "github.com/ThreeDotsLabs/monolith-microservice-shop/pkg/orders/interfaces/public/http" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | var testCases = []struct { 19 | Name string 20 | OrdersServiceAddress string 21 | ShopServiceAddress string 22 | }{ 23 | { 24 | Name: "monolith", 25 | OrdersServiceAddress: "http://monolith:8080", // running from container, so :8080, not :8090 26 | ShopServiceAddress: "http://monolith:8080", 27 | }, 28 | { 29 | Name: "microservices", 30 | OrdersServiceAddress: "http://orders:8080", // running from container, so :8080, not :8070 31 | ShopServiceAddress: "http://shop:8080", 32 | }, 33 | } 34 | 35 | func TestOrderPath(t *testing.T) { 36 | for _, tc := range testCases { 37 | t.Run(tc.Name, func(t *testing.T) { 38 | orderID := placeOrder(t, tc.OrdersServiceAddress, "1") 39 | 40 | timeout := time.Now().Add(time.Second) 41 | for { 42 | if isOrderPaid(t, tc.OrdersServiceAddress, orderID) { 43 | break 44 | } 45 | 46 | if time.Now().After(timeout) { 47 | t.Fatal("timeouted: order is not paid") 48 | break 49 | } 50 | 51 | time.Sleep(time.Millisecond * 100) 52 | } 53 | }) 54 | } 55 | 56 | } 57 | 58 | func isOrderPaid(t *testing.T, ordersServiceAddress string, orderID string) bool { 59 | resp := makeRequest(t, "GET", fmt.Sprintf("%s/orders/%s/paid", ordersServiceAddress, orderID), nil) 60 | defer resp.Body.Close() 61 | 62 | respBody, err := ioutil.ReadAll(resp.Body) 63 | require.NoError(t, err) 64 | 65 | paidResponse := orders_http_interface.OrderPaidView{} 66 | err = json.Unmarshal(respBody, &paidResponse) 67 | assert.NoError(t, err) 68 | 69 | return paidResponse.IsPaid 70 | } 71 | 72 | func placeOrder(t *testing.T, ordersServiceAdddress string, productID string) string { 73 | resp := makeRequest(t, "POST", ordersServiceAdddress+"/orders", orders_http_interface.PostOrderRequest{ 74 | ProductID: orders.ProductID(productID), 75 | Address: orders_http_interface.PostOrderAddress{ 76 | Name: "test name", 77 | Street: "test street", 78 | City: "test city", 79 | PostCode: "test post code", 80 | Country: "test country", 81 | }, 82 | }) 83 | 84 | responseData := orders_http_interface.PostOrdersResponse{} 85 | 86 | b, err := ioutil.ReadAll(resp.Body) 87 | require.NoError(t, err) 88 | require.NoError(t, json.Unmarshal(b, &responseData)) 89 | 90 | require.EqualValues(t, http.StatusOK, resp.StatusCode) 91 | 92 | return responseData.OrderID 93 | } 94 | 95 | func TestProducts(t *testing.T) { 96 | expectedProducts := ` 97 | [ 98 | { 99 | "id":"1", 100 | "name":"Product 1", 101 | "description":"Some extra description", 102 | "price":{ 103 | "cents":422, 104 | "currency":"USD" 105 | } 106 | }, 107 | { 108 | "id":"2", 109 | "name":"Product 2", 110 | "description":"Another extra description", 111 | "price":{ 112 | "cents":333, 113 | "currency":"EUR" 114 | } 115 | } 116 | ]` 117 | 118 | for _, tc := range testCases { 119 | t.Run(tc.Name, func(t *testing.T) { 120 | url := fmt.Sprintf("%s/products", tc.ShopServiceAddress) 121 | resp := makeRequest(t, "GET", url, nil) 122 | defer resp.Body.Close() 123 | 124 | greeting, err := ioutil.ReadAll(resp.Body) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | assert.JSONEq(t, expectedProducts, string(greeting)) 129 | }) 130 | } 131 | } 132 | 133 | func makeRequest(t *testing.T, method string, path string, data interface{}) (*http.Response) { 134 | var body []byte 135 | 136 | if data != nil { 137 | var err error 138 | body, err = json.Marshal(data) 139 | assert.NoError(t, err) 140 | } 141 | 142 | req, err := http.NewRequest(method, path, bytes.NewBuffer(body)) 143 | require.NoError(t, err) 144 | req.Header.Set("Content-Type", "application/json") 145 | 146 | resp, err := http.DefaultClient.Do(req) 147 | require.NoError(t, err) 148 | 149 | // resp.Body is io.Reader and is treated as stream, so you can read from it once. 150 | // We must set body again to allow read it again. 151 | bodyCopy, err := ioutil.ReadAll(resp.Body) 152 | require.NoError(t, err) 153 | resp.Body.Close() 154 | 155 | resp.Body = ioutil.NopCloser(bytes.NewBuffer(bodyCopy)) 156 | 157 | fmt.Printf("Request %s %s done, response status: %d: body: %s\n", method, path, resp.StatusCode, bodyCopy) 158 | 159 | return resp 160 | } 161 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= 4 | github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= 5 | github.com/go-chi/render v1.0.0 h1:cLJlkaTB4xfx5rWhtoB0BSXsXVJKWFqv08Y3cR1bZKA= 6 | github.com/go-chi/render v1.0.0/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= 7 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 8 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 9 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 10 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 12 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 13 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 17 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 18 | github.com/streadway/amqp v0.0.0-20180112231532-a354ab84f102 h1:P+/2QYOj3WuExP+Kq+bOFbY/GBz7BBnQaQDVHuaEDWk= 19 | github.com/streadway/amqp v0.0.0-20180112231532-a354ab84f102/go.mod h1:1WNBiOZtZQLpVAyu0iTduoJL9hEsMloAK5XWrtW0xdY= 20 | github.com/stretchr/testify v1.2.0 h1:LThGCOvhuJic9Gyd1VBCkhyUXmO8vKaBFvBsJ2k03rg= 21 | github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 22 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 23 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 24 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 25 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 26 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 27 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 28 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 29 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 30 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 31 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 32 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 33 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 34 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 44 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 45 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 46 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 47 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 48 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 49 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 50 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 51 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 52 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 53 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 54 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 55 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 56 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 57 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 60 | --------------------------------------------------------------------------------