Exploring DDD in Go
Who am I?
Damiano Petrungaro
Who am I?
Staff Engineer @Odin
www.joinodin.com
Who am I?
Milan, Italy
Who am I?
Manga and anime
1. What is DDD?
2. Strategic Design
3. Tactical Design
4. Sample codebase
Agenda
NHS National Programme for IT (2011)
- Amount: ~£10 billion
- Duration: 2002-2011
Failure
- Ambitious Scope
- Changing Requirements
- Lack of Clinical Engagement
What is DDD?
Domain Driven Design
Understanding the problem
=
Domain Driven Design
Understanding the probem
Strategic Design
Tactical Design
Shaping the problem
Implementing the code
1. Understanding the problem
What is DDD?
2. Strategic & Tactical design
Strategic Design
Context Map
Strategic Design
Context Map
Notification (ext)
Billing (ext)
Customer
Product
Order
CS - Admin
Generic subdomain
Core subdomain
Support subdomain
Pricing
Strategic Design (in Go)
package main
import "github.com/company/email"
func main(){
aEmail, err := email.NewAdminEmail("info@whatever.com")
//...
cEmail, err := email.NewCustomerEmail("name.surname@gmail.com")
//...
}
// email.go
type Email string
func NewAdminEmail(s string) (Email, error) {
// validation logic here
}
func NewCustomerEmail(s string) (Email, error) {
// validation logic here
}
Strategic Design (in Go)
package main
import (
"github.com/company/admin"
"github.com/company/customer"
)
func main(){
aEmail, err := admin.NewEmail("info@whatever.com")
//...
cEmail, err := customer.NewEmail("name.surname@gmail.com")
//...
}
// customer/email.go
type Email string
func NewEmail(s string) (Email, error) {
// validation logic here
}
// admin/email.go
type Email string
func NewEmail(s string) (Email, error) {
// validation logic here
}
Strategic Design (in Go)
Go package
Context
=
Notification (ext)
Billing (ext)
Customer
Product
Generic subdomain
Core subdomain
Support subdomain
Order
CS - Admin
Strategic Design
Context Map
Pricing
Strategic Design
Relationship
A
B
Strategic Design
Relationship
A
B
Downstream
upstream
A
B
A
B
customer-supplier
cooperate
separate ways
Strategic Design
Separate ways
Customer
Product
📂 company
┣ 📂 customer
┃ ┗ 📄 customer.go
┣ 📂 product
┃ ┗ 📄 product.go
┣ 📄 go.mod
┗ 📄 go.sum
📂 customer-svc
┣ 📄 customer.go
┣ 📄 go.mod
┗ 📄 go.sum
//
📂 product-svc
┣ 📄 product.go
┣ 📄 go.mod
┗ 📄 go.sum
Strategic Design
Cooperate: Partnership
Pricing
Product
📂 company
┣ 📄 pricing.go
┣ 📄 product.go
┣ 📄 go.mod
┗ 📄 go.sum
Strategic Design
Customer-Supplier: Conformist
Order
CS-Admin
Downstream
upstream
📂 company
┣ 📂 order
┃ ┗ 📄 order.go
┃ ┗ 📂 admin
┃ ┗ 📄 admin.go
┣ 📄 go.mod
┗ 📄 go.sum
📂 company
┣ 📂 order
┃ ┗ 📄 order.go
┣ 📂 admin
┃ ┗ 📄 admin.go // imports company/order
┣ 📄 go.mod
┗ 📄 go.sum
Strategic Design
Customer-Supplier: Anticorruption Layer
📂 order-svc
┣ 📄 order.go
┣ 📄 go.mod
┗ 📄 go.sum
//
📂 admin-svc
┣ 📄 admin.go
┣ 📄 order.go
┣ 📂 acl
┃ ┗ 📄 acl.go
┣ 📄 go.mod
┗ 📄 go.sum
1. Breaking down the problem
2. Importance of packaging
3. Strategic design patterns
Strategic Design
Tactical Design in Go
Value Obejct
Tactical Design in Go
package order
import (
"errors"
)
type emailInterface interface {
GetEmailValue() string
}
type email struct {
value string
}
func NewEmailAddress(address string) (emailInterface, error) {
if !isValidEmail(address) {
return nil, errors.New("Invalid email address")
}
return &email{value: address}, nil
}
func (e *email) String() string {
return e.value
}
func (e *email) GetEmailValue() string {
return e.value
}
func isValidEmail(address string) bool {
// validate email address format
return true
}
Value object - BEFORE
Tactical Design in Go
Value object - AFTER
package order
import (
"errors"
"fmt"
"regexp"
"strings"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$`)
var (
ErrEmail = errors.New("invalid email")
ErrEmailFormat = fmt.Errorf("%w: not a valid format", ErrEmail)
)
// Email represents an email
type Email string
// NewEmail returns a new Email
func NewEmail(s string) (Email, error) {
s = strings.TrimSpace(s)
if !emailRegex.MatchString(s) {
return "", ErrEmailFormat
}
return Email(s), nil
}
// String returns Email as string
func (s Email) String() string {
return string(s)
}
Tactical Design in Go
Aggregate Root
Tactical Design in Go
Aggregate Root - BEFORE
package order
import (
"time"
)
type Order struct {
ID string
Number string
Status string
PlacedBy string
PlacedAt time.Time
ShippedAt time.Time
DeliveredAt time.Time
}
func New(
id string,
number string,
status string,
placedBy string,
placedAt time.Time,
shippedAt time.Time,
deliveredAt time.Time,
) *Order {
return &Order{
ID: id,
Number: number,
Status: status,
PlacedBy: placedBy,
PlacedAt: placedAt,
ShippedAt: shippedAt,
DeliveredAt: deliveredAt,
}
}
package order
import (
"time"
)
type OrderManager struct{
// dependencies ...
}
func (mngr *OrderManager) MarkAsShipped(
o *Order,
shippedAt time.Time,
) {
o.Status = "shipped"
o.ShippedAt = shippedAt
}
func (mngr *OrderManager) MarkAsDelivered(
o *Order,
deliveredAt time.Time,
) {
o.Status = "Delivered"
o.DeliveredAt = deliveredAt
}
Tactical Design in Go
Aggregate Root - AFTER
package order
import (
"errors"
"fmt"
"time"
)
// Domain errors raised by the order aggregate
var (
ErrNotShipped = errors.New("could not mark the order as shipped")
ErrNotDelivered = errors.New("could not mark the order as delivered")
)
// Order represents an order aggregate
type Order struct {
ID ID
Number Number
Status Status
PlacedBy UserID
PlacedAt time.Time
ShippedAt time.Time
DeliveredAt time.Time
}
// Place places a new order
// It is a factory function that uses the ubiquitous language of the domain
func Place(number Number, placedBy UserID) *Order {
return &Order{
ID: NewID(),
Number: number,
Status: Placed,
PlacedBy: placedBy,
PlacedAt: time.Now(),
}
}
// MarkAsShipped marks an order as shipped
// It returns ErrNotShipped when the operation violated the domain invariants
func (o *Order) MarkAsShipped() error {
switch {
case o.PlacedAt.IsZero():
return fmt.Errorf("%w: not placed", ErrNotShipped)
case o.Status == Shipped:
return fmt.Errorf("%w: already shipped", ErrNotShipped)
case o.Status == Delivered:
return fmt.Errorf("%w: already delivered", ErrNotShipped)
// more logic here if needed
}
o.Status = Shipped
o.ShippedAt = time.Now()
return nil
}
// MarkAsDelivered marks an order as delivered
// It returns ErrNotDelivered when the operation violated the domain invariants
func (o *Order) MarkAsDelivered() error {
switch {
case o.ShippedAt.IsZero():
return fmt.Errorf("%w: not shipped", ErrNotDelivered)
case o.Status == Delivered:
return fmt.Errorf("%w: already delivered", ErrNotDelivered)
}
o.Status = Delivered
o.DeliveredAt = time.Now()
return nil
}
Tactical Design in Go
Repository
Tactical Design in Go
Repository
package repository
import (
"context"
"errors"
"github.com/company/order"
)
type Order {
// some fields
}
type OrderOption func(*Order)
func WithCustomerID(customerID int) OrderOption {
return func(o *Order) {
o.CustomerID = customerID
}
}
type OrderRepository struct {
db *sql.DB
}
func (r *OrderRepository) Get(id string) (order.Order, error) {
row := r.db.QueryRow("SELECT * FROM orders WHERE id = ?", id)
var o Order
err := row.Scan(&o.ID /** more data **/)
if err != nil {
return nil, err
}
return mapOrderToDomainOrder(o)
}
func (r *OrderRepository) CreateOrder(opts ...OrderOption) error {
order := &Order{}
for _, opt := range opts {
opt(order)
}
query := "INSERT INTO orders ("
values := []interface{}{}
if order.CustomerID != 0 {
query += "customer_id, "
values = append(values, order.CustomerID)
}
// Remove the trailing comma and space.
query = query[:len(query)-2]
query += ") VALUES ("
for range values {
query += "?, "
}
query = query[:len(query)-2]
query += ")"
_, err := r.db.Exec(query, values...)
if err != nil {
return err
}
return nil
}
func (r *OrderRepository) DeleteOrder(id string) error {
_, err := r.db.Exec("DELETE FROM orders WHERE id = ?", id)
if err != nil {
return err
}
return nil
}
Tactical Design in Go
Repository
package order
import (
"context"
"errors"
)
var (
ErrGet = errors.New("could not get order")
ErrAdd = errors.New("could not add order")
ErrNotFound = errors.New("could not find order")
)
// Repo represents the layer to read/write data from/to the storage
// You can also consider splitting this interface into multiple ones
type Repo interface {
Get(context.Context, ID) (*Order, error)
Add(context.Context, *Order) error
}
Tactical Design in Go
Repository
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
_ "github.com/lib/pq"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db}
}
func (r *Repository) Get(ctx context.Context, id order.ID) (*order.Order, error) {
query := "SELECT id, number, placedAt FROM orders WHERE id = $1"
row := r.db.QueryRowContext(ctx, query, id)
if err := row.Err(); err != nil {
return nil, fmt.Errorf("%w: could not perform query: %w", order.ErrGet, err)
}
o := &order.Order{}
if err := row.Scan(&o.ID, &o.Number, &o.PlacedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %w", order.ErrNotFound, err)
}
return nil, fmt.Errorf("%w: could not scan resut: %w", order.ErrGet, err)
}
return o, nil
}
func (r *Repository) Add(ctx context.Context, o *order.Order) error {
query := "INSERT INTO orders (number, placedAt) VALUES ($1, $2)"
if _, err := r.db.ExecContext(ctx, query, o.Number, o.PlacedAt); err != nil {
return fmt.Errorf("%w: %w", order.ErrAdd, err)
}
return nil
}
Tactical Design in Go
Wiring it up
package internal
import (
...
)
// Errors that the application layer exposes
var (
ErrNotPlaced = errors.New("order could not be placed")
ErrNotMarkedAsShipped = errors.New("order could not be marked as shipped")
ErrNotMarkedAsDelivered = errors.New("order could not be marked as delivered")
)
// Service represent the application layer
// it depends on the domain logic and can be used
// by any infrastructure layer as domain logic orchestrator
type Service struct {
repo order.Repo
}
// Place places an order and store it in the repository
func (s *Service) Place(
ctx context.Context,
n order.Number,
uID order.UserID,
) (*order.Order, error) {
o := order.Place(n, uID)
if err := s.repo.Add(ctx, o); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotPlaced, err)
}
return o, nil
}
// MarkAsShipped marks as shipped an order and store it in the repository
func (s *Service) MarkAsShipped(ctx context.Context, id order.ID) (*order.Order, error) {
o, err := s.repo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsShipped, err)
}
if err := o.MarkAsShipped(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsShipped, err)
}
if err := s.repo.Add(ctx, o); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsShipped, err)
}
return o, nil
}
// MarkAsDelivered marks as delivered an order and store it in the repository
func (s *Service) MarkAsDelivered(ctx context.Context, id order.ID) (*order.Order, error) {
o, err := s.repo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsDelivered, err)
}
if err := o.MarkAsDelivered(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsDelivered, err)
}
if err := s.repo.Add(ctx, o); err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotMarkedAsDelivered, err)
}
return o, nil
}
Wiring things up
1. Value object
2. Aggregate Root
3. Repository
4. Keep it idiomatic
Tactical Design
github.com/damianopetrungaro/go-ddd
Example App
Contacts
- Twitter: @damiano_dev
- LinkedIn: /in/damianopetrungaro
- Blog: damianopetrungaro.com