Implementing Domain-Driven Design with Go
In my previous article, I wrote about Domain-Driven Design. In this article, we go further. We implement the design to code with Go / GoLang.
In Tactical Design, the minimum design we do is create domain layer boxes with ubiquitous languages. The boxes are value objects, entities, aggregates, factories, repositories, and services. It is confusing stuff if we only read the blue book. That's because the blue book only explains the theory and doesn't explain in more detail the implementation. That's why people implement them in many different ways.
I recommend these books; Implementing Domain-Driven Design by Vaughn Vernon, Domain-Driven Design with Golang by Mathew Boyle, and Practical DDD in Golang by Marko Milojevic. Those are my references in implementing DDD and writing this article.
Let me show you how I implemented DDD here. I hope this article can help you, serve as your reference, and provide enough GoLang implementation. So, we don't need to open which page in the book because everything is already in this article.
Enum / Enumeration
Some properties have limited fixed values. Maybe those values are enough to be stored as a number or int
in the database but need to show as a string for users or humans. Those values are not objects. They are enums.
package main
import "fmt"
type OrderStatus int
const (
Undefined OrderStatus = iota
ProcessedOrder
SuccessOrder
FailedOrder
)
func (o OrderStatus) String() string {
switch o {
case ProcessedOrder:
return "PROCESSED_ORDER"
case SuccessOrder:
return "SUCCESS_ORDER"
case FailedOrder:
return "FAILED_ORDER"
}
return "UNDEFINED"
}
func main() {
fmt.Println(OrderStatus(1).String()) // PROCESSED_ORDER
}
It is better to place them in the same package if we have multiple enums. It's better to place them in a specific package if those enums are used in a lot of objects or models out there. Let's say enum
.
undefined.go
package enum
const Undefined = 0
order_status.go
package enum
type OrderStatus int
const (
ProcessedOrder OrderStatus = 1 << iota
SuccessOrder
FailedOrder
)
func (o OrderStatus) String() string {
switch o {
case ProcessedOrder:
return "PROCESSED_ORDER"
case SuccessOrder:
return "SUCCESS_ORDER"
case FailedOrder:
return "FAILED_ORDER"
}
return "UNDEFINED"
}
invoice_status.go
package enum
type InvoiceStatus int
const (
CreatedInvoice InvoiceStatus = 1 << iota
PaidInvoice
RejectedInvoice
)
func (i InvoiceStatus) String() string {
switch i {
case CreatedInvoice:
return "CREATED_INVOICE"
case PaidInvoice:
return "PAID_INVOICE"
case RejectedInvoice:
return "REJECTED_INVOICE"
}
return "UNDEFINED"
}
main.go
package main
func main() {
var test OrderStatus = 0
var undefined OrderStatus = Undefined
fmt.Println(OrderStatus(2).String()) // SUCCESS_ORDER
fmt.Println(test.String(), test == Undefined, undefined == Undefined) // UNDEFINED true true
}
The iota
is commonly used for an enum in GoLang. Just put the iota
at the top of the constants, then it will make the rest have ordered values and the same types.
Value Object
When you care only about the attributes of an element of the model, classify it as a VALUE OBJECT. Make it express the meaning of the attributes it conveys and give it related functionality. Treat the VALUE OBJECT as immutable. Don't give it any identity and avoid the design complexities necessary to maintain ENTITIES.
package main
import (
"errors"
"fmt"
"log"
)
type Name struct {
first string
last string
}
func (n Name) First() string {
return n.first
}
func (n Name) Last() string {
return n.last
}
func (n Name) Full() string {
if n.last == "" {
return n.First()
}
return fmt.Sprintf("%s %s", n.first, n.last)
}
func (n Name) IsEmpty() bool {
return n.First() == ""
}
func NewName(first, last string) (Name, error) {
if first == "" {
return Name{}, errors.New("first name cannot be empty")
}
return Name{first, last}, nil
}
func main() {
name, err := NewName("John", "")
if err != nil {
log.Fatal(err)
}
fmt.Println(name.Full()) // John
}
One of the common value objects, Name. It is empty or invalid if it doesn't have a first name. The last name is optional. It has private properties and public getters. It has no setter. Create the new one if we need to change the value. Because the value object is immutable.
While working in DDD, we are practicing the Single-Responsibility Principle of SOLID. We only need to fix the object that has an issue. Let's say Email.
package main
import (
"fmt"
"log"
"net/mail"
"strings"
)
type Email struct {
local string
domain string
}
func (e Email) Local() string {
return e.local
}
func (e Email) Domain() string {
return e.domain
}
func (e Email) String() string {
return fmt.Sprintf("%s@%s", e.Local(), e.Domain())
}
func (e Email) IsEmpty() bool {
return e.Local() == "" || e.Domain() == ""
}
func (e Email) IsEqualTo(other Email) bool {
return e.Local() == other.Local() && e.Domain() == other.Domain()
}
func NewEmail(email string) (Email, error) {
e, err := mail.ParseAddress(email)
if err != nil {
return Email{}, err
}
chunks := strings.Split(e.Address, "@")
return Email{chunks[0], strings.Join(chunks[1:], "@")}, nil
}
func main() {
email, err := NewEmail("john.doe@example.com")
if err != nil {
log.Fatal(err)
}
fmt.Println(email.String()) // john.doe@example.com
}
We only need to fix that value object if we have an issue with Email. That's one of the reasons why DDD is better than anemic.
We can combine all value objects in one package. We also can combine them in sub-packages based on entities if there are too many of them for one package.
Entity
Value Objects have no identity. They are equal if the values are equal. You can see the IsEqualTo()
function on the Email. Entities have identifiers. Their values could be the same as others, but they are different if the identifier is different.
Some objects are not defined primarily by their attributes. They represent a thread of identity that runs through time and often across distinct representations. Sometimes such an object must be matched with another object even though attributes differ. An object must be distinguished from other objects even though they might have the same attributes. Mistaken identity can lead to data corruption.
The entity in DDD does not represent a database table or JSON object. It is an entity of the domain or business. Because of that, the ID shouldn't be an int
or uint
, but it should be a real identifier. We can use UUID for general, or a value object if that fulfills the domain.
package main
import (
"errors"
"github.com/google/uuid"
)
type User struct {
id uuid.UUID
email Email
name Name
}
func (u *User) ID() uuid.UUID {
return u.id
}
func (u *User) Email() Email {
return u.email
}
func (u *User) UpdateEmail(email Email) error {
if email.IsEmpty() {
return errors.New("email cannot be empty")
}
u.email = email
return nil
}
func (u *User) Name() Name {
return u.name
}
func (u *User) UpdateName(name Name) error {
if name.IsEmpty() {
return errors.New("name cannot be empty")
}
u.name = name
return nil
}
func NewUser(email Email, name Name) (*User, error) {
if email.IsEmpty() {
return nil, errors.New("email cannot be empty")
}
if name.IsEmpty() {
return nil, errors.New("name cannot be empty")
}
return &User{uuid.New(), email, name}, nil
}
We can't return the struct only for entities like value objects because there are some properties we need to update. In the case above, the user can update the email because that isn't the identifier for users. There are cases in some businesses where email is the identifier for users.
type Product struct {
sku SKU
info Info
price Price
salePrice Price
}
func (p *Product) SKU() SKU {
return p.sku
}
func (p *Product) Info() Info {
return p.info
}
func (p *Product) UpdateInfo(info Info) error {
if info.IsEmpty() {
return errors.New("info cannot be empty")
}
p.info = info
return nil
}
func (p *Product) Price() Price {
return p.price
}
func (p *Product) UpdatePrice(price Price) error {
if price.IsEmpty() {
return errors.New("price cannot be empty")
}
p.price = price
return nil
}
func (p *Product) SalePrice() Price {
return p.salePrice
}
func (p *Product) UpdateSalePrice(salePrice Price) {
p.salePrice = salePrice
}
func NewProduct(sku SKU, info Info, price, salePrice Price) (*Product, error) {
if sku.IsEmpty() {
return nil, errors.New("SKU cannot be empty")
}
if info.IsEmpty() {
return nil, errors.New("info cannot be empty")
}
if price.IsEmpty() {
return nil, errors.New("price cannot be empty")
}
return &Product{sku, info, price, salePrice}, nil
}
In the case above, SKU is an identifier for Products, not UUID.
Aggregate
Cluster the ENTITIES and VALUE OBJECTS into AGGREGATES and define boundaries around each. Choose one ENTITY to be the root of each AGGREGATE, and control all access to the objects inside the boundary through the root. Allow external objects to hold references to the root only. Transient references to internal members can be passed out for use within a single operation only. Because the root controls access, it cannot be blindsided by changes to the internals. This arrangement makes it practical to enforce all invariants for objects in the AGGREGATE and for the AGGREGATE as a whole in any state change.
type Car struct {
id uuid.UUID
mileage Mileage
wheels Wheels
}
func (c *Car) ID() uuid.UUID {
return c.id
}
func (c *Car) Mileage() Mileage {
return c.mileage
}
func (c *Car) AddWheel(wheel *Wheel) error {
if len(c.wheels) >= 4 {
return errors.New("wheels are full")
}
if wheel == nil {
return errors.New("wheel cannot be empty")
}
c.wheels = append(c.wheels, wheel)
return nil
}
func (c *Car) Move() error {
if len(c.wheels) < 4 {
return errors.New("wheels are not enough")
}
c.mileage.Add(c.wheels.Move())
}
func NewCar() *Car {
return &Car{
id: uuid.New(),
mileage: NewMileage(),
}
}
Aggregate is the center of the domain. As we see from the simple example above, the Car aggregate holds entities of wheels, type Wheels []*Wheel
. So, the main logic of the domain or business of the car is on that aggregate and the wheels are entities for the car. We can say that Car is also an entity because it has an identifier. The car can't move if it has wheels below 4. That rule is called Business Invariant.
type Driver struct {
id uuid.UUID
ownedCars OwnedCars
}
func (d *Driver) AddCar(car *Car) error {
if car == nil {
return errors.New("car cannot be empty")
}
if d.ownedCars[car.ID()] {
return errors.New("car already owned by the driver")
}
d.ownedCars[car.ID()] = true
return nil
}
func (d *Driver) Drive(car *Car) error {
if !d.ownedCars[car.ID()] {
return errors.New("the car is not owned by the driver")
}
if err := car.Move(); err != nil {
return err
}
return nil
}
func NewDriver() *Driver {
return &Driver{
id: uuid.New(),
}
}
We need to cluster the aggregates. We need to put boundaries between them. So, each aggregate only focuses on its business. They only hold the reference if they have a relationship.
From the example above, the driver only can drive the car that they have.
Repository
A client needs a practical means of acquiring references to preexisting domain objects. If the infrastructure makes it easy to do so, the developers of the client may add more traversable associations, muddling the model. On the other hand, they may use queries to pull the exact data they need from the database, or to pull a few specific objects rather than navigating from AGGREGATE roots. Domain logic moves into queries and client code, and the ENTITIES and VALUE OBJECTS become mere data containers. The sheer technical complexity of applying most database access infrastructure quickly swamps the client code, which leads developers to dumb down the domain layer, which makes the model irrelevant.
type CarRepository interface {
Count(context.Context, Specification) (uint, error)
Search(context.Context, Specification) (Cars, error)
Get(context.Context, uuid.UUID) (*Car, error)
Save(context.Context, *Car) error
Update(context.Context, *Car) error
Delete(context.Context, uuid.UUID) error
}
Methods of the Car repository above are common examples. There are context.Context
on every method. In GoLang, we can put meta-information in the context.Context
to pass it to the client. There are specifications that contain parameters for quering.
A repository is just about data storage. The business for the repository is just about managing data. A repository is an interface. It is an Anti-Corruption layer. It uses the Dependency Inversion Principle of SOLID. The implementations are infrastructure layers. So, we can have many implementations for the repository such as using a map variable, a file, MySQL, PostgreSQL, MongoDB, third parties, etc.
type carRepositoryMySQL struct {
db *sql.DB
}
func (r *carRepositoryMySQL) Count(ctx context.Context, spec Specification) (uint, error) {
// TODO
}
func (r *carRepositoryMySQL) Search(ctx context.Context, spec Specification) (Cars, error) {
// TODO
}
func (r *carRepositoryMySQL) Get(ctx context.Context, id uuid.UUID) (*Car, error) {
// TODO
}
func (r *carRepositoryMySQL) Save(ctx context.Context, car *Car) error {
// TODO
}
func (r *carRepositoryMySQL) Update(ctx context.Context, car *Car) error {
// TODO
}
func (r *carRepositoryMySQL) Delete(ctx context.Context, id uuid.UUID) error {
// TODO
}
func NewCarRepositoryMySQL(db *sql.DB) (CarRepository, error) {
if db == nil {
return nil, errors.New("database connection cannot be empty")
}
return &carRepositoryMySQL{db}
}
Service
When a significant process or transformation in the domain is not a natural responsibility of an ENTITY or VALUE OBJECT, add an operation to the model as a standalone interface declared as a SERVICE. Define the interface in terms of the language of the model and make sure the operation name is part of the UBIQUITOUS LANGUAGE. Make the SERVICE stateless.
Services have been partitioned into three layers; application, domain, and infrastructure. I made an example for each layer here.
Application
type CarService struct {
carRepository CarRepository
}
func (s *CarService) SearchCarsWithTotal(ctx context.Context, spec Specification) (Cars, uint, error) {
total, err := s.carRepository.Count(ctx, nil)
if err != nil {
return nil, 0, err
}
cars, err := s.carRepository.Search(ctx, spec)
if err != nil {
return nil, 0, err
}
return cars, total, nil
}
func NewCarService(carRepository CarRepository) (*CarService, error) {
if carRepository == nil {
return nil, errors.New("car repository cannot be empty")
}
return &CarService{carRepository}, nil
}
GoLang can return multiple values. We returned cars with total in the method of SearchCarsWithTotal()
. This CarService
doesn't have to be in the design because it is an application layer. Sometimes referred to as "use cases". Application services can be a gateway for presentation layers to repositories or domain/infrastructure services.
Domain
type CarService struct {
car *Car
carRepository CarRepository
}
func (s *CarService) DrivenBy(driver *Driver) error {
if driver == nil {
return errors.New("driver cannot be empty")
}
if err := driver.Drive(s.car); err != nil {
return err
}
if err := s.carRepository.Update(s.car); err != nil {
return err
}
return nil
}
if NewCarService(car *Car, carRepository CarRepository) (*CarService, error) {
if car == nil {
return nil, errors.New("car cannot be empty")
}
if carRepository == nil {
return nil, errors.New("car repository cannot be empty")
}
return &CarService{car, carRepository}, nil
}
The CarService
above is different from the one in the application. That is the CarService
for the domain. The car can be driven by any driver who has the authority to drive the car and cannot move without a driver. We created a domain service for it because of those rules and it would be too complex for an entity.
Infrastructure
type ConfirmationService interface {
SendConfirmation(context.Context, User) error
}
We use interfaces for infrastructure services. As Anti-Corruption layers and to apply DIP of SOLID. Because there could be many implementations for an interface or service. Let's say there are email or SMS as confirmation services.
import (
"crypto/tls"
"errors"
"gopkg.in/gomail.v2"
)
type emailConfirmationService struct {
dialer *gomail.Dialer
}
func (s *emailConfirmationService) SendConfirmation(ctx context.Context, user User) error {
// TODO
}
func NewEmailConfirmationService(host string, port int, username, password string) (ConfirmationService, error) {
if host == "" {
return nil, errors.New("host cannot be empty")
}
if port <= 0 {
return nil, errors.New("port cannot be empty")
}
if username == "" {
return nil, errors.New("username cannot be empty")
}
if password == "" {
return nil, errors.New("password cannot be empty")
}
dialer := gomail.NewDialer(host, port, username, password)
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
return &emailConfirmationService{dialer}, nil
}
In the example above, we create an implementation of the confirmation service by email. Even sending email has many ways. I used gopkg.in/gomail.v2
for that one, maybe we create another version by using the built-in Go package, net/smtp
. That's why infrastructure services should be interfaces.
Factory
When creation of an object, or an entire AGGREGATE, becomes complicated or reveals too much of the internal structure, FACTORIES provide encapsulation.
The constructors above, the function that starts with New
, are factories. You can visit my previous article about Design Patterns. We can use any creational pattern as factories in DDD.
type CarFactory struct {
}
func (f *CarFactory) Create(type CarType) (*Car, error) {
switch type {
case ElectricCar:
return NewCar(NewElectricEngine()), nil
case GasCar:
return NewCar(NewGasEngine()), nil
}
return nil, errors.New("unknown car type")
}
In the simple example above, I apply Factory Method for CarFactory.Create()
. The CarFactory
has no property. But that's how to create an object or class in GoLang. Because the CarFactory
is in the design and that name is using Ubiquitous Language.
func RebuildCar(id uuid.UUID, mileage MileAge, engine Engine) *Car {
return &Car{id, mileage, engine}
}
The RebuildCar()
above is different from the NewCar()
. It has no validation. Because it is to rebuild the object from the repository.
type car struct {
id string
mileage float64
carType CarType
}
func (c car) Domain() *Car {
id, _ := uuid.Parse(c.id)
return RebuildCar(
id,
RebuildMileage(c.mileage),
RebuildEngine(c.carType),
)
}
I created a model for database implementation, car
. It is a private struct
. No need to export it. This model will create a Data Transfer Object (DTO) and transform it into the domain layer.
Specification
SPECIFICATION provides a concise way of expressing certain kinds of rules, extricating them from conditional logic and making them explicit in the model.
There are three types of Specifications; Validation, Selection (or Querying), and Building to Order (Generating).
Validation
type CarSpecification interface {
IsValid(Car) bool
}
type CarSpecifications []CarSpecification
type AndSpecification struct {
specifications CarSpecifications
}
func (s AndSpecification) IsValid(car *Car) bool {
for _, specification := range s.specifications {
if !specification.IsValid(car) {
return false
}
}
return true
}
func NewAndSpecification(specifications CarSpecifications) CarSpecification
return AndSpecification{specifications}
}
type HasAtLeast struct {
wheels int
}
func (h HasAtLeast) IsValid(car *Car) bool {
return len(car.Wheels()) >= h.wheels
}
func NewHasAtLeast(wheels int) CarSpecification {
return HasAtLeast{wheels}
}
func IsElectric(car *Car) bool {
return car.CarType() == ElectricCar
}
type FunctionSpecification func(*Car) bool
func (s FunctionSpecification) IsValid(car *Car) bool {
return s(car)
}
func main() {
spec := NewAndSpecification(
NewHasAtLeast(4),
FunctionSpecification(IsElectric),
)
fmt.Println(spec.IsValid(&Car{})) // false
fmt.Println(spec.IsValid(RebuildCar(
uuid.New(),
NewMileage(),
RebuildEngine(ElectricCar),
))) // true
}
We can use the specification to validate the domain object. We can put it to our constructors like the examples above but it is optional because we already have validation there.
Selection (or Querying)
type Specification interface {
Query() string
Value() []any
}
type Specifications []Specification
type AndSpecification struct {
specifications Specifications
}
func (s AndSpecification) Query() string {
queries := []string{}
for _, specification := range s.specifications {
queries = append(queries, specification.Query())
}
return fmt.Sprintf("(%s)", strings.Join(queries, " AND "))
}
func (s AndSpecification) Value() []any {
values := []any{}
for _, specification := range s.specifications {
values = append(values, specification.Value()...)
}
return values
}
func NewAndSpecification(specifications Specifications) Specification
return AndSpecification{specifications}
}
type HasAtLeast struct {
wheels int
}
func (h HasAtLeast) Query() string {
return "wheels >= ?"
}
func (h HasAtLeast) Value() []any {
return []any{h.wheels}
}
func NewHasAtLeast(wheels int) Specification {
return HasAtLeast{wheels}
}
func IsElectric() string {
return "car_type = 'ELECTRIC'"
}
type FunctionSpecification func() string
func (s FunctionSpecification) Query() string {
return s()
}
func (s FunctionSpecification) Value() []any {
return nil
}
func main() {
spec := NewAndSpecification(
NewHasAtLeast(4),
FunctionSpecification(IsElectric),
)
fmt.Println(spec.Query()) // (wheels >= ? AND car_type = 'ELECTRIC')
fmt.Println(spec.Value()) // [4]
}
This kind of specification is commonly used in repositories and is a little bit different from other specifications. We can create a general specification, Specification
, and it will suit any entity. So, instead of passing lots of parameters on repository methods, we can pass a specification.
Building to Order (Generating)
type CarSpecification interface {
Create(*Car) *Car
}
type CarSpecifications []CarSpecification
type AndSpecification struct {
specifications CarSpecifications
}
func (s AndSpecification) Create(car *Car) *Car {
for _, specification := range s.specifications {
car = specification.Create(car)
}
return car
}
func NewAndSpecification(specifications CarSpecifications) CarSpecification
return AndSpecification{specifications}
}
type HasAtLeast struct {
wheels int
}
func (h HasAtLeast) Create(car *Car) *Car {
car.wheels = h.wheels
return car
}
func NewHasAtLeast(wheels int) CarSpecification {
return HasAtLeast{wheels}
}
func IsElectric(car *Car) *Car {
car.carType = ElectricCar
return car
}
type FunctionSpecification func(car *Car) *Car
func (s FunctionSpecification) Create(car *Car) *Car {
return s(car)
}
func main() {
spec := NewAndSpecification(
NewHasAtLeast(4),
FunctionSpecification(IsElectric),
)
fmt.Println(spec.Create(NewCar())) // &{[209 95 205 104 63 171 68 43 140 244 84 192 34 75 236 80] 0 4 1}
}
We can combine this creational specification with factories, including constructors. [209 95 205 104 63 171 68 43 140 244 84 192 34 75 236 80]
is an array of bytes, which is the ID (UUID). 0 is the mileage. Of course, the new car has zero mileage. 4 is car wheels. 1 is an enum, which means electric car or ELECTRIC
.
Event
Vaughn Vernon mentions Domain Events in his book entitled Implementing Domain-Driven Design, the red book, which is not mentioned by Eric Evans in his book, the blue book. The event that I mention here has two layers; application and domain. The point is that we treat events as objects. So, technically the code is the same for both application and domain events, the only difference is the purpose. Catching events in DDD helps a lot. Especially when we do Event Storming.
Model information about activity in the domain as a series of discrete events. Represent each event as a domain object. ... A domain event is a full-fledged part of the domain model, a representation of something that happened in the domain.
import (
"errors"
"github.com/google/uuid"
)
type Event interface {
Name() string
}
type OrderEvent interface {
Event
ID() uuid.UUID
}
type ProcessedOrder struct {
id uuid.UUID
}
func (o ProcessedOrder) Name() string {
return ProcessedOrder.String()
}
func (o ProcessedOrder) ID() uuid.UUID {
return o.id
}
func NewProcessedOrder(id uuid.UUID) (OrderEvent, error) {
if id == uuid.Nil {
return nil, errors.New("order ID cannot be empty")
}
return &ProcessedOrder{id}, nil
}
type SuccessOrder struct {
id uuid.UUID
}
func (o SuccessOrder) Name() string {
return SuccessOrder.String()
}
func (o SuccessOrder) ID() uuid.UUID {
return o.id
}
func NewSuccessOrder(id uuid.UUID) (OrderEvent, error) {
if id == uuid.Nil {
return nil, errors.New("order ID cannot be empty")
}
return &SuccessOrder{id}, nil
}
type FailedOrder struct {
id uuid.UUID
}
func (o FailedOrder) Name() string {
return FailedOrder.String()
}
func (o FailedOrder) ID() uuid.UUID {
return o.id
}
func NewFailedOrder(id uuid.UUID) (OrderEvent, error) {
if id == uuid.Nil {
return nil, errors.New("order ID cannot be empty")
}
return &FailedOrder{id}, nil
}
Those are basic events. They will be created and published by aggregates and sent to other or bounded contexts to be handled by event handlers. Technically, if those contexts are other services, then we need RabbitMQ, Kafka, etc to publish and subscribe to those events.
type CartService struct {
cart *Cart
orderRepository OrderRepository
orderEventPublisher OrderEventPublisher
}
func (s *CartService) PlaceOrder() error {
order, processedOrder, err := s.cart.PlaceOrder()
if err != nil {
return err
}
if err := s.orderRepository.Save(order); err != nil {
return err
}
if err := s.orderEventPublisher.Publish(processedOrder); err != nil {
return err
}
return nil
}
That is an example of how an event is created and published. An event of processed order is created by an aggregate of Cart
and published in the domain service of CartService
if successful to save in the repository. You can put s.orderEventPublisher.Publish()
on a Goroutine and ignore error handling there probably because you log them enough or for the sake of performance.
type OrderEventPublisher interface {
Publish(OrderEvent) error
}
type orderEventPublisherRabbitMQ struct {
}
func (p *orderEventPublisherRabbitMQ) Publish(orderEvent OrderEvent) error {
// TODO
}
func NewOrderEventPublisherRabbitMQ() OrderEventPublisher {
return &orderEventPublisherRabbitMQ{}
}
OrderEventPublisher
is an interface because it has many implementations.
type EventHandler interface {
Handle(Event) error
}
type processedOrderEventHandler struct {
orderRepository OrderRepository
warehouseService WarehouseService
}
func (h *processedOrderEventHandler) Handle(event Event) error {
order, err := h.orderRepository.Get(event.(OrderEvent).ID())
if err != nil {
return err
}
if err := h.warehouseService.Ship(order); err != nil {
return err
}
return nil
}
func NewProcessedOrderEventHandler(
orderRepository OrderRepository,
warehouseService WarehouseService,
) (EventHandler, error) {
if orderRepository == nil {
return nil, errors.New("order repository cannot be empty")
}
if warehouseService == nil {
return nil, errors.New("warehouse service cannot be empty")
}
return &processedOrderEventHandler{orderRepository, warehouseService}, nil
}
That's just an example of event handlers. Ideally, event handlers are interfaces. So, they can be stacked or multiple for an event.
type EventName string
type EventHandlers map[EventName][]EventHandler
type eventHandlerRabbitMQ struct {
eventHandlers EventHandlers
}
func (h *eventHandlerRabbitMQ) Run() {
// TODO
}
func NewEventHandlerRabbitMQ(eventHandlers EventHandlers) *eventHandlerRabbitMQ {
return &eventHandlerRabbitMQ{eventHandlers}
}
func main() {
eventHandlers := EventHandlers{} // TODO: append event handlers here
eventHandler := NewEventHandlerRabbitMQ(eventHandlers)
eventHandler.Run()
}
Event handlers should be in the standalone process because they listen to events. Private is enough like eventHandlerRabbitMQ
.
Module
Modules are packages in GoLang. So, we need to cluster those models to be modular. It is about folders and what stuff should be exported. Modules are not the same as contexts.
├── cmd │ ├── app │ │ ├── main.go ├── internal │ ├── car │ │ ├── car.go │ │ ├── car_repository.go │ │ ├── car_repository_mysql.go │ │ ├── car_application_service.go │ │ ├── car_domain_service.go │ │ ├── car_specification.go │ │ ├── car_http_handler.go │ │ ├── wheel.go │ │ ├── tire.go │ ├── driver │ │ ├── driver_repository.go │ │ ├── driver_application_service.go │ ├── order │ │ ├── order_repository.go │ │ ├── order_repository_grpc.go │ │ ├── processed_order_event_handler.go ├── pkg │ ├── status │ │ ├── invoice_status.go │ │ ├── order_status.go │ │ ├── undefined.go │ ├── event │ │ ├── event.go │ │ ├── event_handler.go │ ├── name │ │ ├── name.go │ ├── email │ │ ├── email.go
All folders or packages under internal
cannot be exported. Those packages in the pkg
folder are general enough. So, we put them there for export.
All the examples in this article are pretty simple. I hope you understand what the point is.
Related Articles
- Domain-Driven Design
- Design Patterns with Go and TypeScript
- Build, Test, and Run Go Microservices with Bazel
- Monorepo vs Polyrepo, Microservices with Monorepo in Go
- Hugo vs Jekyll