С помощью микросервисной архитектуры можно построить масштабируемое и гибкое приложение. Однако, если команда бессистемно использует этот подход в своей работе, то скоро столкнется с разочарованием и неконтролируемой сложностью. Избежать этого поможет DDD (Domain-Driven Design, предметно ориентированное проектирование). Не так давно я ничего не знал про этот подход, но сейчас я постоянно натыкаюсь на эту тему.
Представляю вам перевод статьи "How to Implement Domain-Driven Design (DDD) in Golang". Повествование буду вести от лица автора, иногда прерывая собственными мыслями в таком же формате, как и это отступление. Приятного чтения.
В этой статье мы построим с нуля онлайн-таверну, шаг за шагом исследуя различные части DDD. Надеюсь, разобраться с этим методом проектирования будет проще, если последовательно реализовывать небольшой учебный проект. Я выбрал для статьи именно такой способ повествования, потому что каждый раз, когда я читаю про DDD, моя голова буквально взрывается от разнообразия и сложности терминов.
Если вы не понимаете, о чём я, то эта схема, возможно, поможет понять мою боль.
Первым делом я хочу отметить, что эта статья описывает моё представление о DDD, а реализация, которую я здесь покажу, основана на подходах, хорошо зарекомендовавших себя в Go-проектах, над которыми я работал; нельзя сказать, что описываемый здесь подход — признанный best practice. Буду придерживаться именования папок проекта согласно терминологии DDD. В реальных проектах я бы предпочёл другой способ, но так будет легче усвоить новые понятия. Шаг в сторону боевого проекта мы сделаем ближе к заключению: там я рефакторингом переработаю структуру и объясню, зачем это нужно.
Я не раз наблюдал горячие дискуссии в интернете о DDD и его правильном использовании. И каждый раз обращал внимание, что чаще всего люди забывают про основную цель DDD и зацикливаются на спорах о подробностях реализации. По-моему, главное — следовать предложенной Эвансом методологии, а не следить за тем, чтобы сущности были названы так или иначе.
DDD — это большая область знаний. Сейчас мы сосредоточимся на практической реализации подхода. Но сперва давайте пробежимся по основным аспектам предметно ориентированного проектирования.
Что такое DDD
Domain-Driven Design — это способ структурирования знаний и создания упрощенной модели той предметной области (домена, domain), к которой относится разрабатываемое ПО. В первую очередь необходимо изучить предметную область. Ею может быть любая задача или проблема, решению которой должна способствовать создаваемая программа. При этом приложение следует конструировать так, чтобы его архитектура отражала структуру предметной области.
Согласно DDD, команда разработки должна постоянно взаимодействовать с экспертами в предметной области (Subject Matter Experts, SME). Именно эти люди хранят знания, которые необходимо отразить в коде приложения. Например, если вам нужно создать приложение для торговли акциями, то было бы неплохо сперва побольше узнать о фондовом рынке из уст человека, обладающего большим опытом.
Архитектура должна отражать в коде структуру предметной области. Как именно — мы увидим, когда начнём писать наше приложение для управления таверной.
Путешествие гофера в DDD
Начнём с небольшой истории про гофера Данте, который хочет создать виртуальную таверну. Данте знает, как писать код, но понятия не имеет, как управлять таверной. В тот день, когда Данте решает начать работу над программой, у него возникает вопрос: с чего начать? Он выходит на прогулку, обдумывая свои планы. Пока Данте стоит на светофоре, к нему подходит человек в высокой шляпе и говорит: «Похоже, вы чем-то обеспокоены, молодой человек. Не нужна ли вам помощь в создании таверны?».
Данте и человек в высокой шляпе отлично погуляли и обсудили управление таверной. Данте узнал, что приходящих в таверну людей стоит называть клиентами, а не выпивохами. Также «шляпа» объяснил, что в таверне не только обслуживают клиентов, но и управляют сотрудниками, поставками и платежами.
Домен, модель, универсальный язык и поддомены
Надеюсь, вам понравилась история про Данте, ведь я написал её не просто так. Она послужит нам контекстом, который позволит проще объяснить некоторые ключевые понятия.
Герои нашей истории поучаствовали в сессии моделирования предметной области: «шляпа» выступал в качестве эксперта, а Данте представлял команду инженеров. Такие беседы помогают построить правильную абстракцию над теми компонентами, которые нужны для создания модели.
В нашем случае основным доменом (Core/Root domain) будет таверна. Однако Шляпа упомянул и о других понятиях; так все члены команды, работающей над проектом, формируют универсальный язык (ubiquitous language), без которого общение сбивало бы с толку, а не помогало развитию.
Также выделились несколько поддоменов — важных областей, без которых нельзя представить работающую таверну. Поддомен — отдельная «вложенная» предметная область, которая используется для решения локального набора задач внутри основной предметной области.
Код DDD-приложения на Go
Сущности и объекты-значения
Пора начинать писать код нашего приложения, у нас есть всё, что необходимо. Начнём с создания окружения проекта.
mkdir ddd-go
go mod init github.com/percybolmer/ddd-go
Первым делом создадим папку domain
, в которой будем хранить все необходимые поддомены. Но перед тем, как мы начнём работу с доменом, нам нужна другая папка. В учебных целях назовем её entity
и положим туда сущности (entity) — структуры с идентификатором, которые могут изменять состояние.
Мы создадим две сущности: Person
и Item
. Я предпочитаю держать сущности в отдельном пакете, чтобы их можно было использовать из всех других доменов.
Для поддержания чистоты кода я делаю маленькие файлы и компактную структуру проекта, в которой легко ориентироваться. Так что рекомендую создать два отдельных файла для каждой сущности. Сейчас там будут только определения структур, но позже мы добавим и логику.
package entity
import "github.com/google/uuid"
// Person is a entity that represents a person in all Domains
type Person struct {
// ID is the identifier of the Entity, the ID is shared for all sub domains
ID uuid.UUID
Name string
Age int
}
package entity
import "github.com/google/uuid"
// Item represents a Item for all sub domains
type Item struct {
ID uuid.UUID
Name string
Description string
}
Перейдём к объектам-значениям (Value Objects) — неизменяемым структурам, которым не нужен идентификатор. Состояние таких объектов не изменяется после создания. Объекты-значения часто находятся внутри доменов и используются для описания конкретных аспектов в них. Сейчас создадим один объект-значение Transaction
, после этого она уже не сможет изменить своё состояние. В реальном приложении ID для транзакции необходим, но для учебных целей этого примера достаточно.
package valueobject
import "time"
type Transaction struct {
// all values lowercase since they are immutable
amount int
from uuid.UUID
to uuid.UUID
createdAt time.Time
}
Агрегаты: комбинированные сущности и объекты-значения
Агрегат (Aggregate) — это набор скомбинированных сущностей и объектов-значений. В нашем случае можно начать с создания агрегата Customer
. Роль агрегатов — быть объектом бизнес-логики. Они не дают прямого доступа к хранимым сущностям, которых может быть несколько. Например, для определения покупателя нам потребуется не только сущность Person
, но и Products
вместе с Transactions
.
У агрегата в DDD должна быть только одна главная сущность (root entity). Тогда, ссылаясь на основную сущность, мы также будем ссылаться и на агрегат. Например, ID сущности Person может служить для однозначного определения агрегата Customer
.
Давайте создадим папку aggregate
, в которую положим файл customer.go
. Там заведём новую структуру Customer
, в которой будут все необходимые для представления клиента сущности. Обратите внимание, что все поля структуры начинаются с маленькой буквы (так мы запретим доступ к этим полям вне пакета структуры). Это осознанное решение, потому что агрегаты не должны предоставлять прямого доступа к своим данным. Также мы не добавляем никаких тегов в структуру, чтобы её нельзя было использовать для сериализации или сохранения в базу.
package aggregate
import (
"github.com/percybolmer/ddd-go/entity"
"github.com/percybolmer/ddd-go/valueobject"
)
// Customer is a aggregate that combines all entities needed to represent a customer
type Customer struct {
// person is the root entity of a customer
// which means the person.ID is the main identifier for this aggregate
person *entity.Person
// a customer can hold many products
products []*entity.Item
// a customer can perform many transactions
transactions []valueobject.Transaction
}
Все сущности заведены указателями, потому что они могут изменять своё состояние. И я хочу отразить это в коде.
Фабрики: способ сокрытия сложной логики
До сих пор мы с вами только заводили различные сущности, объекты-значения и агрегаты. Настало время реализовать часть бизнес-логики, и начнём мы с фабрик (factories). Паттерн «фабрика» используется для сокрытия сложной логики внутри функций, которые создают желаемые экземпляры объектов. При этом вызывающий такую функцию код ничего не знает о деталях реализации и особенностях создания желаемого объекта.
Есть отличный сайт-шпаргалка, который поможет вам ознакомиться или вспомнить основные паттерны проектирования.
Фабрика — очень распространенный паттерн, который можно использовать и за рамками DDD-приложений, и вы, скорее всего, уже делали это не один раз.
DDD предлагает использовать фабрики для создания сложных агрегатов, репозиториев и сервисов (познакомимся с этими терминами далее). Мы реализуем фабрику NewCustomer
. Происходящее внутри этой функции скрыто от вызывающего её кода и никак на него не влияет.
В реальном приложении я бы предпочел держать агрегат в папке domains/customer
рядом с фабрикой, но подробнее об этом поговорим ниже.
package aggregate
import (
"errors"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/entity"
"github.com/percybolmer/ddd-go/valueobject"
)
var ErrInvalidPerson = errors.New("a customer has to have an valid person")
type Customer struct {
person *entity.Person
products []*entity.Item
transactions []valueobject.Transaction
}
// NewCustomer is a factory to create a new Customer aggregate
// It will validate that the name is not empty
func NewCustomer(name string) (Customer, error) {
if name == "" {
return Customer{}, ErrInvalidPerson
}
// Create a new person and generate ID
person := &entity.Person{
Name: name,
ID: uuid.New(),
}
// Create a customer object and initialize all the values to avoid nil pointer exceptions
return Customer{
person: person,
products: make([]*entity.Item, 0),
transactions: make([]valueobject.Transaction, 0),
}, nil
}
Сейчас фабрика взяла на себя всю ответственность по валидации входных данных, созданию нового ID и заданию всех начальных значений. Поскольку у нас появилась логика, пора добавить тесты. Добавляем файл customer_test.go
в пакет aggregate
.
package aggregate_test
import (
"testing"
"github.com/percybolmer/ddd-go/aggregate"
)
func TestCustomer_NewCustomer(t *testing.T) {
// Build our needed testcase data struct
type testCase struct {
test string
name string
expectedErr error
}
// Create new test cases
testCases := []testCase{
{
test: "Empty Name validation",
name: "",
expectedErr: aggregate.ErrInvalidPerson,
}, {
test: "Valid Name",
name: "Percy Bolmer",
expectedErr: nil,
},
}
for _, tc := range testCases {
// Run Tests
t.Run(tc.test, func(t *testing.T) {
// Create a new customer
_, err := aggregate.NewCustomer(tc.name)
// Check if the error matches the expected error
if err != tc.expectedErr {
t.Errorf("Expected error %v, got %v", tc.expectedErr, err)
}
})
}
}
Обратите внимание на реализацию табличных тестов от автора. Такой подход позволяет избежать дублирования кода и прогнать тест на разных входных значениях.
Но далеко с текущими возможностями таверны мы не уедем. Пора взглянуть на самый лучший паттерн, который мне известен.
Репозитории
DDD говорит, что репозитории должны хранить и управлять агрегатами. Изучив этот паттерн, я сразу понял, что никогда не перестану его использовать. Он полагается на сокрытие взаимодействия с хранилищем за интерфейсом реализации. Главное преимущество такого подхода заключается в возможности заменить реализацию без опасения что-либо сломать. Мы можем ограничиться локальным хранилищем на период разработки и переключиться позже на MongoDB, например, без необходимости переписывать что-либо кроме самого кода взаимодействия с хранилищем. Это поможет не только при замене вендора, но и при тестировании: можно с лёгкостью замокать хранилище с помощью новой реализации интерфейса.
Начнём с создания файла repository.go
внутри пакета domain/customer
. Заведём там общие ошибки и интерфейс, который мы хотели бы видеть у хранилища.
// Package Customer holds all the domain logic for the customer domain.
package customer
import (
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
)
var (
ErrCustomerNotFound = errors.New("the customer was not found in the repository")
ErrFailedToAddCustomer = errors.New("failed to add the customer to the repository")
ErrUpdateCustomer = errors.New("failed to update the customer in the repository")
)
// CustomerRepository is a interface that defines the rules around what a customer repository
type CustomerRepository interface {
Get(uuid.UUID) (aggregate.Customer, error)
Add(aggregate.Customer) error
Update(aggregate.Customer) error
}
Сам я предпочитаю делать небольшие интерфейсы, заточенные под конкретную потребность: в функции, которая получает пользователя, вряд ли может потребоваться его обновить. Расширить такой интерфейс при необходимости совсем не сложно. А изначальный минимализм помогает сохранить изолированность логики.
Обратите внимание, что ошибки определены именно в пакете
customer
, а не в пакете репозитория. Это позволит не зависеть от реализации.
Далее хочется написать реализацию определённого ранее интерфейса. И начну я с хранилища в памяти. В конце статьи посмотрим, как заменить хранилище реальным без нарушения совместимости с соседними частями приложения.
По моему опыту лучше держать каждую реализацию внутри собственной директории. Это позволит новым разработчикам легче вникать в проект, и искать нужные куски кода будет проще. Альтернативное решение — ограничиться файлом — на практике быстро приводит к замусориванию проекта.
mkdir memory
touch memory/memory.go
Перед работой над репозиторием нам нужно добавить немного функциональности Customer
-у.
func (c *Customer) GetID() uuid.UUID {
return c.person.ID
}
func (c *Customer) SetID(id uuid.UUID) {
if c.person == nil {
c.person = &entity.Person{}
}
c.person.ID = id
}
func (c *Customer) SetName(name string) {
if c.person == nil {
c.person = &entity.Person{}
}
c.person.Name = name
}
func (c *Customer) GetName() string {
return c.person.Name
}
Создадим новую структуру, фабрику для неё и методы для реализации интерфейса.
// Package memory is a in-memory implementation of the customer repository
package memory
import (
"fmt"
"sync"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
"github.com/percybolmer/ddd-go/domain/customer"
)
// MemoryRepository fulfills the CustomerRepository interface
type MemoryRepository struct {
customers map[uuid.UUID]aggregate.Customer
sync.Mutex
}
// New is a factory function to generate a new repository of customers
func New() *MemoryRepository {
return &MemoryRepository{
customers: make(map[uuid.UUID]aggregate.Customer),
}
}
// Get finds a customer by ID
func (mr *MemoryRepository) Get(id uuid.UUID) (aggregate.Customer, error) {
if customer, ok := mr.customers[id]; ok {
return customer, nil
}
return aggregate.Customer{}, customer.ErrCustomerNotFound
}
// Add will add a new customer to the repository
func (mr *MemoryRepository) Add(c aggregate.Customer) error {
if mr.customers == nil {
// Saftey check if customers is not create, shouldn't happen if using the Factory, but you never know
mr.Lock()
mr.customers = make(map[uuid.UUID]aggregate.Customer)
mr.Unlock()
}
// Make sure Customer isn't already in the repository
if _, ok := mr.customers[c.GetID()]; ok {
return fmt.Errorf("customer already exists: %w", customer.ErrFailedToAddCustomer)
}
mr.Lock()
mr.customers[c.GetID()] = c
mr.Unlock()
return nil
}
// Update will replace an existing customer information with the new customer information
func (mr *MemoryRepository) Update(c aggregate.Customer) error {
// Make sure Customer is in the repository
if _, ok := mr.customers[c.GetID()]; !ok {
return fmt.Errorf("customer does not exist: %w", customer.ErrUpdateCustomer)
}
mr.Lock()
mr.customers[c.GetID()] = c
mr.Unlock()
return nil
}
Как всегда, необходимо добавить тесты. И хочется отметить удобство репозиториев в том числе и при написании тестов: очень просто заменить реальный репозиторий на тестовый, который воспроизведёт условия возникновения багов.
Мне кажется, приведённый тут пример слаб. Если сделать пакет
memory_test
(как обычно и происходит), то доступа к внутренней мапе клиентов у нас не будет и красота примера меркнет. Но сила репозиториев, скрытых за интерфейсами, всё равно неоспорима. Нужно лишь сказать, что в тестах обычно заменяешь репозиторий его моком, и вот тут описанный подход раскрывает свой потенциал.
package memory
import (
"testing"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
"github.com/percybolmer/ddd-go/domain/customer"
)
func TestMemory_GetCustomer(t *testing.T) {
type testCase struct {
name string
id uuid.UUID
expectedErr error
}
// Create a fake customer to add to repository
cust, err := aggregate.NewCustomer("Percy")
if err != nil {
t.Fatal(err)
}
id := cust.GetID()
// Create the repo to use, and add some test Data to it for testing
// Skip Factory for this
repo := MemoryRepository{
customers: map[uuid.UUID]aggregate.Customer{
id: cust,
},
}
testCases := []testCase{
{
name: "No Customer By ID",
id: uuid.MustParse("f47ac10b-58cc-0372-8567-0e02b2c3d479"),
expectedErr: customer.ErrCustomerNotFound,
}, {
name: "Customer By ID",
id: id,
expectedErr: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := repo.Get(tc.id)
if err != tc.expectedErr {
t.Errorf("Expected error %v, got %v", tc.expectedErr, err)
}
})
}
}
func TestMemory_AddCustomer(t *testing.T) {
type testCase struct {
name string
cust string
expectedErr error
}
testCases := []testCase{
{
name: "Add Customer",
cust: "Percy",
expectedErr: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
repo := MemoryRepository{
customers: map[uuid.UUID]aggregate.Customer{},
}
cust, err := aggregate.NewCustomer(tc.cust)
if err != nil {
t.Fatal(err)
}
err = repo.Add(cust)
if err != tc.expectedErr {
t.Errorf("Expected error %v, got %v", tc.expectedErr, err)
}
found, err := repo.Get(cust.GetID())
if err != nil {
t.Fatal(err)
}
if found.GetID() != cust.GetID() {
t.Errorf("Expected %v, got %v", cust.GetID(), found.GetID())
}
})
}
}
Отлично, первый репозиторий готов. Помните, что нужно держать его тесно связанным с соответствующим доменом (поддерживаем высокую сопряжённость, high cohesion). Сейчас репозиторий управляет только одним агрегатом Customer
, так и должно быть. Не стоит добавлять сюда управление другими агрегатами, ведь мы хотим сохранять слабую связность (loose coupling).
Но как нам тогда реализовывать логику работы таверны, если нельзя полагаться на один Customer Repository? Скоро мы начнём собирать вместе разные репозитории и выстраивать процесс работы таверны.
Сервисы: реализуем бизнес-логику
Мы создали все эти сущности, агрегаты и репозитории, но до сих пор наш код не выглядит как цельное приложение. Поэтому нам нужен такой компонент как «сервис». Он свяжет все разрозненные компоненты в бизнес-логику, которая удовлетворит нуждам конкретного домена. В случае с таверной нам понадобится сервис Order
, который будет связывать воедино репозитории (CustomerRepository
и ProductRepository
) для исполнения заказа.
Обычно сервисы имеют доступ ко всем репозиториям, которые необходимы им для исполнения заданной бизнес-логики: Order
, Api
или Billing
. Прелесть ещё заключается в том, что сервисы можно помещать внутрь других сервисов.
Все сервисы будем держать в папке services
. Начнём с файла order.go
и OrderService
, который будет управлять заказами в таверне. У нас пока не хватает некоторых доменов, так что заведём CustomerRepository
, а остальные добавим позже.
// Package services holds all the services that connects repositories into a business flow
package services
import "github.com/percybolmer/ddd-go/domain/customer"
// OrderConfiguration is an alias for a function that will take in a pointer to an OrderService and modify it
type OrderConfiguration func(os *OrderService) error
type OrderService struct {
customers customer.CustomerRepository
}
// NewOrderService takes a variable amount of OrderConfiguration functions and returns a new OrderService
// Each OrderConfiguration will be called in the order they are passed in
func NewOrderService(cfgs ...OrderConfiguration) (*OrderService, error) {
os := &OrderService{}
// Apply all Configurations passed in
for _, cfg := range cfgs {
err := cfg(os)
if err != nil {
return nil, err
}
}
return os, nil
}
// WithCustomerRepository applies a given customer repository to the OrderService
func WithCustomerRepository(cr customer.CustomerRepository) OrderConfiguration {
// return a function that matches the OrderConfiguration alias,
// You need to return this so that the parent function can take in all the needed parameters
return func(os *OrderService) error {
os.customers = cr
return nil
}
}
// WithMemoryCustomerRepository applies a memory customer repository to the OrderService
func WithMemoryCustomerRepository() OrderConfiguration {
// Create the memory repo, if we needed parameters, such as connection strings they could be inputted here
cr := memory.New()
return WithCustomerRepository(cr)
}
Автор просит обратить внимание на способ инициализации сервиса. Я не стану уделять этому много времени, потому что информации и так достаточно. Если хотите узнать подробнее, изучите код и ознакомьтесь с оригиналом.
Теперь можно добавлять методы к сервису, чтобы клиенты получили возможность покупать что-нибудь в таверне.
// CreateOrder will chain together all repositories to create a order for a customer
func (o *OrderService) CreateOrder(customerID uuid.UUID, productIDs []uuid.UUID) error {
// Get the customer
c, err := o.customers.Get(customerID)
if err != nil {
return err
}
// Get each Product, Ouchie, We need a ProductRepository
return nil
}
Упс, в нашей таверне нечего продавать. Но мы уже знаем, как это исправить: необходимо создать репозиторий для предлагаемых в таверне блюд и напитков.
ProductRepository: заключительный кусочек таверны
Теперь, когда мы знаем, для чего предназначен каждый компонент в DDD, настало время попрактиковаться. Для начала можно расширить ProductRepository
, чтобы находить составляющие заказов. С этого момента будет чуть меньше объяснений, потому что мы уже рассмотрели основы.
Создадим новый агрегат Product
и фабрику для него.
// File: product.go
// Product is an aggregate that represents a product.
package aggregate
import (
"errors"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/entity"
)
var ErrMissingValues = errors.New("missing values")
// Product is a aggregate that combines item with a price and quantity
type Product struct {
// item is the root entity which is an item
item *entity.Item
price float64
// Quantity is the number of products in stock
quantity int
}
func NewProduct(name, description string, price float64) (Product, error) {
if name == "" || description == "" {
return Product{}, ErrMissingValues
}
return Product{
item: &entity.Item{
ID: uuid.New(),
Name: name,
Description: description,
},
price: price,
quantity: 0,
}, nil
}
func (p Product) GetID() uuid.UUID {
return p.item.ID
}
func (p Product) GetItem() *entity.Item {
return p.item
}
func (p Product) GetPrice() float64 {
return p.price
}
Обязательно добавим тесты, чтобы убедиться в правильности работы кода.
package aggregate_test
import (
"testing"
"github.com/percybolmer/ddd-go/aggregate"
)
func TestProduct_NewProduct(t *testing.T) {
type testCase struct {
test string
name string
description string
price float64
expectedErr error
}
testCases := []testCase{
{
test: "should return error if name is empty",
name: "",
expectedErr: aggregate.ErrMissingValues,
},
{
test: "validvalues",
name: "test",
description: "test",
price: 1.0,
expectedErr: nil,
},
}
for _, tc := range testCases {
t.Run(tc.test, func(t *testing.T) {
_, err := aggregate.NewProduct(tc.name, tc.description, tc.price)
if err != tc.expectedErr {
t.Errorf("Expected error: %v, got: %v", tc.expectedErr, err)
}
})
}
}
В файле domain/product/repository.go
определим интерфейс ProductRepository
, реализация которого обеспечит доступ к товарам.
// Package product holds the repository and the implementations for a ProductRepository
package product
import (
"errors"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
)
var (
ErrProductNotFound = errors.New("the product was not found")
ErrProductAlreadyExist = errors.New("the product already exists")
)
// ProductRepository is the repository interface to fulfill to use the product aggregate
type ProductRepository interface {
GetAll() ([]aggregate.Product, error)
GetByID(id uuid.UUID) (aggregate.Product, error)
Add(product aggregate.Product) error
Update(product aggregate.Product) error
Delete(id uuid.UUID) error
}
Как и в случае с репозиторием клиентов, реализуем хранилище в оперативной памяти: для этого в домене product
создадим папку memory
.
// Package memory is a in memory implementation of the ProductRepository interface.
package memory
import (
"sync"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
"github.com/percybolmer/ddd-go/domain/product"
)
type MemoryProductRepository struct {
products map[uuid.UUID]aggregate.Product
sync.Mutex
}
// New is a factory function to generate a new repository of customers
func New() *MemoryProductRepository {
return &MemoryProductRepository{
products: make(map[uuid.UUID]aggregate.Product),
}
}
// GetAll returns all products as a slice
// Yes, it never returns an error, but
// A database implementation could return an error for instance
func (mpr *MemoryProductRepository) GetAll() ([]aggregate.Product, error) {
// Collect all Products from map
var products []aggregate.Product
for _, product := range mpr.products {
products = append(products, product)
}
return products, nil
}
// GetByID searches for a product based on it's ID
func (mpr *MemoryProductRepository) GetByID(id uuid.UUID) (aggregate.Product, error) {
if product, ok := mpr.products[uuid.UUID(id)]; ok {
return product, nil
}
return aggregate.Product{}, product.ErrProductNotFound
}
// Add will add a new product to the repository
func (mpr *MemoryProductRepository) Add(newprod aggregate.Product) error {
mpr.Lock()
defer mpr.Unlock()
if _, ok := mpr.products[newprod.GetID()]; ok {
return product.ErrProductAlreadyExist
}
mpr.products[newprod.GetID()] = newprod
return nil
}
// Update will change all values for a product based on it's ID
func (mpr *MemoryProductRepository) Update(upprod aggregate.Product) error {
mpr.Lock()
defer mpr.Unlock()
if _, ok := mpr.products[upprod.GetID()]; !ok {
return product.ErrProductNotFound
}
mpr.products[upprod.GetID()] = upprod
return nil
}
// Delete remove an product from the repository
func (mpr *MemoryProductRepository) Delete(id uuid.UUID) error {
mpr.Lock()
defer mpr.Unlock()
if _, ok := mpr.products[id]; !ok {
return product.ErrProductNotFound
}
delete(mpr.products, id)
return nil
}
Как всегда, добавим тесты.
package memory
import (
"testing"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
"github.com/percybolmer/ddd-go/domain/product"
)
func TestMemoryProductRepository_Add(t *testing.T) {
repo := New()
product, err := aggregate.NewProduct("Beer", "Good for you're health", 1.99)
if err != nil {
t.Error(err)
}
repo.Add(product)
if len(repo.products) != 1 {
t.Errorf("Expected 1 product, got %d", len(repo.products))
}
}
func TestMemoryProductRepository_Get(t *testing.T) {
repo := New()
existingProd, err := aggregate.NewProduct("Beer", "Good for you're health", 1.99)
if err != nil {
t.Error(err)
}
repo.Add(existingProd)
if len(repo.products) != 1 {
t.Errorf("Expected 1 product, got %d", len(repo.products))
}
type testCase struct {
name string
id uuid.UUID
expectedErr error
}
testCases := []testCase{
{
name: "Get product by id",
id: existingProd.GetID(),
expectedErr: nil,
}, {
name: "Get non-existing product by id",
id: uuid.New(),
expectedErr: product.ErrProductNotFound,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := repo.GetByID(tc.id)
if err != tc.expectedErr {
t.Errorf("Expected error %v, got %v", tc.expectedErr, err)
}
})
}
}
func TestMemoryProductRepository_Delete(t *testing.T) {
repo := New()
existingProd, err := aggregate.NewProduct("Beer", "Good for you're health", 1.99)
if err != nil {
t.Error(err)
}
repo.Add(existingProd)
if len(repo.products) != 1 {
t.Errorf("Expected 1 product, got %d", len(repo.products))
}
err = repo.Delete(existingProd.GetID())
if err != nil {
t.Error(err)
}
if len(repo.products) != 0 {
t.Errorf("Expected 0 products, got %d", len(repo.products))
}
}
Добавим OrderService
-у возможность использовать ProductRepository
с помощью соответствующей опции.
// OrderService is a implementation of the OrderService
type OrderService struct {
customers customer.CustomerRepository
products product.ProductRepository
}
// WithMemoryProductRepository adds a in memory product repo and adds all input products
func WithMemoryProductRepository(products []aggregate.Product) OrderConfiguration {
return func(os *OrderService) error {
// Create the memory repo, if we needed parameters, such as connection strings they could be inputted here
pr := prodmemory.New()
// Add Items to repo
for _, p := range products {
err := pr.Add(p)
if err != nil {
return err
}
}
os.products = pr
return nil
}
}
Давайте прокачаем метод CreateOrder
: теперь можно искать заказанные продукты и возвращать суммарную их стоимость.
// CreateOrder will chain together all repositories to create a order for a customer
// will return the collected price of all Products
func (o *OrderService) CreateOrder(customerID uuid.UUID, productIDs []uuid.UUID) (float64, error) {
c, err := o.customers.Get(customerID)
if err != nil {
return 0, err
}
// Get each Product, Ouchie, We need a ProductRepository
var products []aggregate.Product
var price float64
for _, id := range productIDs {
p, err := o.products.GetByID(id)
if err != nil {
return 0, err
}
products = append(products, p)
price += p.GetPrice()
}
// All Products exists in store, now we can create the order
log.Printf("Customer: %s has ordered %d products", c.GetID(), len(products))
return price, nil
}
Поправим тесты order_test.go
, чтобы сервис создавался с обоими репозиториями.
package services
import (
"testing"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
)
func init_products(t *testing.T) []aggregate.Product {
beer, err := aggregate.NewProduct("Beer", "Healthy Beverage", 1.99)
if err != nil {
t.Error(err)
}
peenuts, err := aggregate.NewProduct("Peenuts", "Healthy Snacks", 0.99)
if err != nil {
t.Error(err)
}
wine, err := aggregate.NewProduct("Wine", "Healthy Snacks", 0.99)
if err != nil {
t.Error(err)
}
products := []aggregate.Product{
beer, peenuts, wine,
}
return products
}
func TestOrder_NewOrderService(t *testing.T) {
// Create a few products to insert into in memory repo
products := init_products(t)
os, err := NewOrderService(
WithMemoryCustomerRepository(),
WithMemoryProductRepository(products),
)
if err != nil {
t.Error(err)
}
// Add Customer
cust, err := aggregate.NewCustomer("Percy")
if err != nil {
t.Error(err)
}
err = os.customers.Add(cust)
if err != nil {
t.Error(err)
}
// Perform Order for one beer
order := []uuid.UUID{
products[0].GetID(),
}
_, err = os.CreateOrder(cust.GetID(), order)
if err != nil {
t.Error(err)
}
}
Tavern: сервис, который содержит другие сервисы
Приступаем к заключительной части: сервису Tavern
. У него будет доступ к OrderService
, чтобы создавать заказы. Такая упаковка помогает реализовать дополнительную логику. Например, для Tavern
целесообразно управлять счетами. Обратите внимание, как легко мы можем встроить логику заказов в этот сервис, не заботясь о подробностях реализации.
package services
import (
"log"
"github.com/google/uuid"
)
// TavernConfiguration is an alias that takes a pointer and modifies the Tavern
type TavernConfiguration func(os *Tavern) error
type Tavern struct {
// orderservice is used to handle orders
OrderService *OrderService
// BillingService is used to handle billing
// This is up to you to implement
BillingService interface{}
}
// NewTavern takes a variable amount of TavernConfigurations and builds a Tavern
func NewTavern(cfgs ...TavernConfiguration) (*Tavern, error) {
// Create the Tavern
t := &Tavern{}
// Apply all Configurations passed in
for _, cfg := range cfgs {
// Pass the service into the configuration function
err := cfg(t)
if err != nil {
return nil, err
}
}
return t, nil
}
// WithOrderService applies a given OrderService to the Tavern
func WithOrderService(os *OrderService) TavernConfiguration {
// return a function that matches the TavernConfiguration signature
return func(t *Tavern) error {
t.OrderService = os
return nil
}
}
// Order performs an order for a customer
func (t *Tavern) Order(customer uuid.UUID, products []uuid.UUID) error {
price, err := t.OrderService.CreateOrder(customer, products)
if err != nil {
return err
}
log.Printf("Bill the Customer: %0.0f", price)
// Bill the customer
// err = t.BillingService.Bill(customer, price)
return nil
}
Чтобы попробовать сервис в деле, можно создать тест.
package services
import (
"testing"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
)
func Test_Tavern(t *testing.T) {
// Create OrderService
products := init_products(t)
os, err := NewOrderService(
WithMemoryCustomerRepository(),
WithMemoryProductRepository(products),
)
if err != nil {
t.Error(err)
}
tavern, err := NewTavern(WithOrderService(os))
if err != nil {
t.Error(err)
}
cust, err := aggregate.NewCustomer("Percy")
if err != nil {
t.Error(err)
}
err = os.customers.Add(cust)
if err != nil {
t.Error(err)
}
order := []uuid.UUID{
products[0].GetID(),
}
// Execute Order
err = tavern.Order(cust.GetID(), order)
if err != nil {
t.Error(err)
}
}
Давайте теперь заменим реализацию CustomerRepository
. Выберем в качестве реального хранилища MongoDB. Тут паттерн «репозиторий» засияет во всей красе. Я очень ценю возможность заменить с такой лёгкостью репозиторий.
Сам я неоднократно слышал, что в проектах меняют базу данных. Например, MySQL на PostgreSQL, но в наших проектах такой потребности ещё не было. Правда, однажды пришлось менять библиотеку для работы с MongoDB. И это было больно. Так что даже если вы не рассматриваете сценарий смены хранилища как возможный, то ещё один повод прятать реализации за интерфейсами — облегчение замены коды, взаимодействующего с вашим хранилищем.
Создадим пакет mongo
внутри домена customer
и структуру, удовлетворяющую репозиторию CustomerRepository
.
Хочется отметить внутреннюю структуру mongoCustomer
, которая используется непосредственно для управления данными из MongoDB. Мы не стали использовать для этих целей уже готовый aggregate.Customer
, потому что это связало бы агрегат и хранилище (в агрегат просочились бы bson-теги и заменить при желании хранилище стало бы сложно). А мы, наоборот, хотим, чтобы репозиторий полностью отвечал за (де)сериализацию данных, не распространяя свои внутренние сущности вовне. Поэтому мы и не использовали непосредственно в структуре агрегата теги json
или bson
. Перегонять же из внутренней структуры в агрегат и наоборот будем с помощью функций.
// Mongo is a mongo implementation of the Customer Repository
package mongo
import (
"context"
"time"
"github.com/google/uuid"
"github.com/percybolmer/ddd-go/aggregate"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MongoRepository struct {
db *mongo.Database
// customer is used to store customers
customer *mongo.Collection
}
// mongoCustomer is an internal type that is used to store a CustomerAggregate
// we make an internal struct for this to avoid coupling this mongo implementation to the customeraggregate.
// Mongo uses bson so we add tags for that
type mongoCustomer struct {
ID uuid.UUID `bson:"id"`
Name string `bson:"name"`
}
// NewFromCustomer takes in a aggregate and converts into internal structure
func NewFromCustomer(c aggregate.Customer) mongoCustomer {
return mongoCustomer{
ID: c.GetID(),
Name: c.GetName(),
}
}
// ToAggregate converts into a aggregate.Customer
// this could validate all values present etc
func (m mongoCustomer) ToAggregate() aggregate.Customer {
c := aggregate.Customer{}
c.SetID(m.ID)
c.SetName(m.Name)
return c
}
// Create a new mongodb repository
func New(ctx context.Context, connectionString string) (*MongoRepository, error) {
client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
if err != nil {
return nil, err
}
// Find Metabot DB
db := client.Database("ddd")
customers := db.Collection("customers")
return &MongoRepository{
db: db,
customer: customers,
}, nil
}
func (mr *MongoRepository) Get(id uuid.UUID) (aggregate.Customer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result := mr.customer.FindOne(ctx, bson.M{"id": id})
var c mongoCustomer
err := result.Decode(&c)
if err != nil {
return aggregate.Customer{}, err
}
// Convert to aggregate
return c.ToAggregate(), nil
}
func (mr *MongoRepository) Add(c aggregate.Customer) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
internal := NewFromCustomer(c)
_, err := mr.customer.InsertOne(ctx, internal)
if err != nil {
return err
}
return nil
}
func (mr *MongoRepository) Update(c aggregate.Customer) error {
panic("to implement")
}
Далее нужно добавить функцию-конфигуратор для внедрения нового репозитория в OrderService
.
func WithMongoCustomerRepository(connectionString string) OrderConfiguration {
return func(os *OrderService) error {
// Create the mongo repo, if we needed parameters, such as connection strings they could be inputted here
cr, err := mongo.New(context.Background(), connectionString)
if err != nil {
return err
}
os.customers = cr
return nil
}
}
Теперь можно с лёгкостью заменить один репозиторий на другой.
func Test_MongoTavern(t *testing.T) {
// Create OrderService
products := init_products(t)
os, err := NewOrderService(
WithMongoCustomerRepository("mongodb://localhost:27017"),
WithMemoryProductRepository(products),
)
if err != nil {
t.Error(err)
}
tavern, err := NewTavern(WithOrderService(os))
if err != nil {
t.Error(err)
}
cust, err := aggregate.NewCustomer("Percy")
if err != nil {
t.Error(err)
}
err = os.customers.Add(cust)
if err != nil {
t.Error(err)
}
order := []uuid.UUID{
products[0].GetID(),
}
// Execute Order
err = tavern.Order(cust.GetID(), order)
if err != nil {
t.Error(err)
}
}
Теперь наша таверна умеет работать как с in-memory хранилищем, так и с MongoDB.
Вот мы и познакомились с основами предметно-ориентированного проектирования. Основные термины, с которыми мы поработали:
сущности (entities) — изменяемые структуры с идентификатором;
объекты-значения (value objects) — неизменяемые структуры без идентификаторов;
агрегаты (aggregates) — комбинация сущностей и объектов-значений под управлением репозиториев;
репозиторий (repository) — реализация управления агрегатами;
фабрика (factory) — конструктор для создания сложных объектов и упрощения получения их экземпляров в соседних доменах;
сервис (service) — набор репозиториев и, возможно, других сервисов, которые вместе формируют бизнес-логику приложения.
Помните, что до этого момента мы называли все компоненты и пакеты согласно принятым в DDD терминам. Так было легче разбираться с новыми понятиями и идеями. Это учебный пример, в реальном же проекте я бы не стал именно так формировать структуру проекта и правила именования. Поэтому далее я приведу рекомендации по рефакторингу проекта, чтобы он больше соответствовал чистой архитектуре.
На самом деле автор написал вторую статью, но я решил, что заканчивать на этом моменте непозволительно.
Рефакторинг
Переносим агрегаты в соответствующие доменные пакеты
Первым делом избавимся от пакета aggregate
. Мы уже разобрались, для чего нужны агрегаты, так что теперь нет необходимости так называть пакет. Я склоняюсь к тому, что агрегаты следует помещать в соответствующий домен. Например, агрегат Customer
окажется в пакете-домене customer
.
Переносим объекты-значения и сущности
У нас ещё есть папки entity
и valueobject
, что, я думаю, не так уж и плохо, ведь хранение разделяемых структур в отдельных пакетах позволяет избежать циклических импортов. Однако того же эффекта можно добиться иным, более изящным способом. Заведем корневой пакет tavern
и переместим туда все сущности и объекты-значения. Получим следующую структуру проекта:
Не забудьте, что в этом случае придётся заново выполнить go mod init
и переименовать импорты.
Автор постоянно напоминает о необходимости поддержания структуры проекта в опрятном виде, чтобы в нём было легко ориентироваться. Но последний шаг мне кажется не самым лучшим продолжением его мыслей. Ведь сейчас на верхнем уровне проекта при его расширении будут плодиться сущности. И в итоге всё равно придётся уносить разделяемые объекты в отдельные папки.
Разделение пакета с сервисами
Сейчас все сервисы находятся в одном пакете. Я предпочту разделить его на два: order
и tavern
. При развитии проекта это предотвратит замусоривание пакета с сервисами. Ещё одним аргументом в пользу такого решения послужит будущее усложнение проекта: запросто может появиться сервис обработки заказов не от индивидуальных клиентов, а от компаний.
Наконец, перед нами итоговый вид проекта.
Заключительный совет, который я хочу вам дать: держите в проекте папку cmd
с пакетами запуска приложений внутри проекта. Это может быть запуск API, consumer-а, однократных скриптов и миграций. В папке cmd
следует сосредоточить использование инструментов для запуска приложения из командной строки и разнообразные настройки при инициализации.
Не забудьте ознакомиться с оригиналами статей (часть 1, часть 2) и продолжайте изучать DDD.
Мне понравилось переводить, но не уверен, что переводы сейчас востребованы. Как вы считаете, необходимо ли переводить такие статьи на русский, или ознакомление в оригинале сейчас доступно каждому?