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

Thank you