Go: how to implement Domain Driven Design (DDD)

Posted by thoand on Tue, 11 Jan 2022 05:14:34 +0100

Learn a simple way to use DDD in Go applications.

In recent years, microservices have become a very popular method of building software. Microservices are used to build scalable and flexible software. However, building microservices randomly across multiple teams can bring great frustration and complexity. Not long ago, I haven't heard of Domain Driven Design - DDD, but now everyone seems to be talking about it wherever they go.

In this article, I will build an online hotel application from scratch to explore various concepts of DDD step by step. It is hoped that each part will be easier to understand DDD. The reason for adopting this method is that I have a headache every time I read DDD materials. There are so many concepts, very broad and unclear, not clear what is what. If you don't know why I have a headache when studying DDD, the following figure may make you realize this.

From the above picture, we can see why Eric Evans used 500 pages to explain what domain driven design is in his Domain Driven Design: solving the complexity of software core. If you are interested in learning DDD, you can read this book.

First of all, I would like to point out that this article describes my understanding of DDD. The implementation I show in this article is based on the best practices derived from my experience in go related projects. The implementation we will create is by no means a best practice accepted by the community. I'll also name the folder DDD in the project to make it easy to understand, but I'm not sure if this is what I want the code framework to look like. Based on this, I will create another branch to modify the code structure, which will be explained in other articles.

I saw a lot of heated discussions on DDD and how to implement it correctly on the Internet. What impresses me is that most of the time people seem to forget the purpose behind DDD and end up discussing some small implementation details. I think it's important to follow Evan's method, not named X or Y.

DDD is a big area, and we will focus on its implementation, but before we implement anything, I will give a quick overview of some concepts in DDD.

What is DDD?

Domain driven design is a method of structuring and modeling software after its domain. This means that the domain of the software written must be considered first. A domain is a subject or problem that the software will deal with. The writing of software should reflect this field.

DDD advocates that engineering teams must talk to subject matter experts (SMEs), who are experts in the field. The reason for this is that SMEs have knowledge about the domain, which should be reflected in the software. Think about it, if I want to be a stock trading platform, as an engineer, do I know enough about this field to be a good stock trading platform? If I could talk to Warren Buffett about this field, the platform might be much better.

The architecture in the code should also reflect the domain. When we start writing our hotel application, we will experience the connotation of the field.

Gopher's DDD Road

Let's start learning how to implement DDD. Before we start, I'll tell you a story about Gopher and Dante, who want to create an online hotel application. Dante knows how to write code, but he doesn't know how to run a hotel.

On the day Dante decided to start creating a hotel application, he encountered a problem. Where and how to start? He went out for a walk and thought about it. While waiting in front of the bus stop sign, a man in a top hat approached Dante and said:

"It looks like you're worried about something, young man. Do you need help building a hotel application?"

Dante took a walk with the big hat man. They discussed the hotel and how to run it. Dante asked how to deal with drinkers. The top hat man corrected that it was the Customer, not the drinker. The top hat man also explained to Dante that the hotel still needs some things to operate, such as customers, employees, banks and suppliers.

Domains, models, unified languages, and sub domains

I hope you like Dante's story. I wrote it for a reason. We can use this story to explain some concepts used in DDD. These words are difficult to explain without context, such as a short story.

Dante and big hat man have discussed a domain model conversation. The top hat man, as an expert in this field, and Dante, as an engineer, discussed the domain space and found common ground. This is done to learn the model, which is an abstraction of the components needed to process the domain. When Dante and the big hat man are discussing hotels, they are discussing related fields. This field is the focus of software operation. I will call Tavern the core / root field.

The top hat man also pointed out that it is not called a drinker, but a customer. This shows how important it is to find a common language between SMO and developers. If not everyone in the project has a common language, it will be very confusing. We also got some sub areas, which are needed for the hotel application mentioned by the top hat man. A sub domain is a separate domain used to solve related things in the root domain.

Write a DDD application using Go - Entities and value objects

We've learned about hotel applications. It's time to write hotel system code. Configure this project by creating go module.

mkdir ddd-go
go mod init github.com/percybolmer/ddd-go

We will create a domain directory to store all sub domains, but before implementing the domain, we need to create another directory under the root directory. For illustrative purposes, we name it entity because it holds the so-called entities in the DDD method. An entity is a structure that contains identifiers. Its state may change. Changing the state means that the value of the entity can change.

First, we will create two entities, Person and Item. I like to keep entities in a separate package so that they can be used by all other domains.

To keep the code clean, I like small files and make the folder structure easy to browse. Therefore, I recommend creating two files, each corresponding to an entity and named after the entity. Now, just include the structure definition, and some other logic will be added later.

Create the first entity for the realm

//The entities package holds all entities shared by all sub domains
package entity

import (
    "github.com/google/uuid"
)

// Person represents people in all areas
type Person struct {
    // ID is the identifier of the entity, which is shared by all sub domains
    ID uuid.UUID `json:"id" bson:"id"`
    //Name is a person's name
    Name string `json:"name" bson:"name"`
    // People's age
    Age int `json:"age" name:"age"`
}
package entity

import "github.com/google/uuid"

// Item represents the item of all sub fields
type Item struct {
    ID          uuid.UUID `json:"id" bson:"id"`
    Name        string    `json:"name" bson:"name"`
    Description string    `json:"description" bson:"description"`
}

ok, now we have defined some entities and understand what entities are. A structure has a unique identifier to reference, and the state is variable.

Some structures are immutable and do not require unique identifiers. These structures are called value objects. Therefore, the structure has no identifier and persistent value after creation. Value objects are usually located in the domain and are used to describe some aspects in the domain. We will now create a value object, which is a Transaction. Once the Transaction is executed, it cannot change the state.

In real applications, tracking transactions through ID S is a good idea, and this is just for demonstration

package valueobject

import (
    "time")

// Transaction means the transaction used by both parties for payment
type Transaction struct {
    Amount    int       `json:"amount" bson:"amount"`
    From      uuid.UUID `json:"from" bson:"from"`
    To        uuid.UUID `json:"to" bson:"to"`
    CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
}

Aggregates - combines Entities and Value Objects

Now let's look at the next component of DDD, aggregation. Aggregation is a combination of a set of entities and value objects. Therefore, in this example, we can first create a new aggregation, Customer.

DDD aggregation is a domain concept (such as orders, clinic visits, playlists) - Martin Fowler

The reason for aggregation is that the business logic will be applied to the Customer aggregation, not to each entity that holds the logic. Aggregation does not allow direct access to underlying entities. In real life, multiple entities are often needed to correctly represent data, such as Customer. It is a Person, but he / she can hold Products and execute transactions.

An important rule in DDD aggregation is that they should have only one entity as the root entity. This means that the reference of the root entity is also used for reference aggregation. For our customer aggregation, this means that the Person ID is a unique identifier.

Let's create an aggregate folder and then create a folder named customer Go file.

mkdir aggregate
cd aggregate
touch customer.go

In this file, we will add a new structure called Customer, which will contain all the entities required to represent Customer. Note that all fields begin with an uppercase letter, which makes them accessible from outside the package in Go. This is contrary to our claim that aggregation does not allow access to underlying entities, but we need it to make aggregation serializable. Another way is to add custom serialization, but I find it sometimes makes sense to skip some rules.

// Package aggregates holds aggregates that combines many entities into a full object
package aggregate

import (
    "github.com/percybolmer/ddd-go/entity"
    "github.com/percybolmer/ddd-go/valueobject"
)

// The Customer aggregation contains all the entities needed to represent a Customer
type Customer struct {
    // Person is the root entity of the customer
    // person.ID is the primary identifier of the aggregate
    Person *entity.Person `bson:"person"`
    //A customer can hold many products
    Products []*entity.Item `bson:"products"`
    // A customer can perform many transactions
    Transactions []valueobject.Transaction `bson:"transactions"`
}

I set all entities as pointers because entities can change state, and I want it to be reflected in all instances that access it at run time. Value objects are saved as non pointers because they cannot change state.

Factory functions - encapsulate complex logic

So far, we have only defined different entities, value objects, and aggregations. Now let's start implementing some actual business logic, starting with factory functions. Factory pattern is a design pattern used to encapsulate complex logic in the function that creates the required instance, and the caller does not know any implementation details.

The factory pattern is a very common pattern that you can even use outside DDD applications, and you may have used it many times. The official Go Elasticsearch client is a good example. You pass a configuration into the NewClient function, which is a factory function that returns that the client connects to the elastic cluster and can insert / delete documents. It is easy for other developers to use. Many things have been done in NewClient:

func NewClient(cfg Config) (*Client, error) {
    var addrs []string

    if len(cfg.Addresses) == 0 && cfg.CloudID == "" {
        addrs = addrsFromEnvironment()
    } else {
        if len(cfg.Addresses) > 0 && cfg.CloudID != "" {
            return nil, errors.New("cannot create client: both Addresses and CloudID are set")
        }

        if cfg.CloudID != "" {
            cloudAddr, err := addrFromCloudID(cfg.CloudID)
            if err != nil {
                return nil, fmt.Errorf("cannot create client: cannot parse CloudID: %s", err)
            }
            addrs = append(addrs, cloudAddr)
        }

        if len(cfg.Addresses) > 0 {
            addrs = append(addrs, cfg.Addresses...)
        }
    }

    urls, err := addrsToURLs(addrs)
    if err != nil {
        return nil, fmt.Errorf("cannot create client: %s", err)
    }

    if len(urls) == 0 {
        u, _ := url.Parse(defaultURL) // errcheck exclude
        urls = append(urls, u)
    }

    // TODO(karmi): Refactor
    if urls[0].User != nil {
        cfg.Username = urls[0].User.Username()
        pw, _ := urls[0].User.Password()
        cfg.Password = pw
    }

    tp, err := estransport.New(estransport.Config{
        URLs:         urls,
        Username:     cfg.Username,
        Password:     cfg.Password,
        APIKey:       cfg.APIKey,
        ServiceToken: cfg.ServiceToken,

        Header: cfg.Header,
        CACert: cfg.CACert,

        RetryOnStatus:        cfg.RetryOnStatus,
        DisableRetry:         cfg.DisableRetry,
        EnableRetryOnTimeout: cfg.EnableRetryOnTimeout,
        MaxRetries:           cfg.MaxRetries,
        RetryBackoff:         cfg.RetryBackoff,

        CompressRequestBody: cfg.CompressRequestBody,

        EnableMetrics:     cfg.EnableMetrics,
        EnableDebugLogger: cfg.EnableDebugLogger,

        DisableMetaHeader: cfg.DisableMetaHeader,

        DiscoverNodesInterval: cfg.DiscoverNodesInterval,

        Transport:          cfg.Transport,
        Logger:             cfg.Logger,
        Selector:           cfg.Selector,
        ConnectionPoolFunc: cfg.ConnectionPoolFunc,
    })
    if err != nil {
        return nil, fmt.Errorf("error creating transport: %s", err)
    }

    client := &Client{Transport: tp}
    client.API = esapi.New(client)

    if cfg.DiscoverNodesOnStart {
        go client.DiscoverNodes()
    }

    return client, nil
}

DDD recommends using factories to create complex aggregations, warehouses, and services. We will implement a factory function that will create a new customer instance. A function named NewCustomer will be created, which accepts a name parameter. What happens inside the function does not need to be known to the domain in which the new customer is created.

NewCustomer will verify that the input contains all the parameters required to create the Customer:

In a real application, I might suggest including aggregated customers and factories in the domain / Customer.

package aggregate

import (
    "errors"

    "github.com/google/uuid"
    "github.com/percybolmer/ddd-go/entity"
    "github.com/percybolmer/ddd-go/valueobject"
)

var (
    // ErrInvalidPerson is returned when person is invalid in the newcustom factory
    ErrInvalidPerson = errors.New("a customer has to have an valid person")
)

type Customer struct {
    // Person is the root entity of the customer
    // person.ID is the main identifier of aggregate
    Person *entity.Person `bson:"person"`
    // A customer can hold many products
    Products []*entity.Item `bson:"products"`
    // A customer can perform many transactions
    Transactions []valueobject.Transaction `bson:"transactions"`
}

// NewCustomer is the factory that creates a new Customer aggregation
// It verifies that the name is empty
func NewCustomer(name string) (Customer, error) {
    // Verify that the Name is not empty
    if name == "" {
        return Customer{}, ErrInvalidPerson
    }

    // Create a new person and generate the ID
    person := &entity.Person{
        Name: name,
        ID:   uuid.New(),
    }
    // Create a customer object and initialize all values to avoid null pointer exceptions
    return Customer{
        Person:       person,
        Products:     make([]*entity.Item, 0),
        Transactions: make([]valueobject.Transaction, 0),
    }, nil
}

The customer factory function now helps validate the input, create a new ID, and ensure that all values are initialized correctly. Now that we have some business logic, we can start adding unit tests. I will create a Customer in the aggregate package_ test. Go, where you test the Customer related logic.

package aggregate_test

import (
    "testing"

    "github.com/percybolmer/ddd-go/aggregate"
)

func TestCustomer_NewCustomer(t *testing.T) {
    // Build the test case data structure we need
    type testCase struct {
        test        string
        name        string
        expectedErr error
    }
    //Create a new test case
    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 that the error matches the expected error
            if err != tc.expectedErr {
                t.Errorf("Expected error %v, got %v", tc.expectedErr, err)
            }

        })
    }

}

We won't go deep into creating new customer s. Now it's time to start looking for the best design pattern I know.

Warehouse - warehouse mode

DDD describes that a warehouse should be used to store and manage aggregations. This is one of the patterns. Once I learn it, I know I will never stop using it. This pattern relies on hiding the implementation of the storage / database solution through the interface. This allows us to define a set of methods that must be used, and if they are implemented, they can be used as a repository.

The advantage of this design pattern is that it allows us to switch solutions without breaking anything. We can use memory storage in the development phase and then switch it to MongoDB storage in the production phase. It not only helps to change the underlying technology used without destroying anything that uses the warehouse, but also is very useful in testing. You can simply implement a new repository for unit tests, etc.

We will first create a file called repository. Enter the domain/customer package. In this file, we will define the functions required by the warehouse. We need the Get, Add and Update functions to handle customers. We will not delete any customers. Once there are customers in the hotel, they will always be customers. We will also implement some common errors in the customer package that can be used by different warehouse implementations.

// The Customer package holds all the domain logic for the Customer domain

import (
    "github.com/google/uuid"
    "github.com/percybolmer/ddd-go/aggregate"
)
var (
    // When no customer is found, ErrCustomerNotFound is returned.
    ErrCustomerNotFound = errors.New("the customer was not found in the repository")
    // ErrFailedToAddCustomer returns when the customer cannot be added to the repository.
    ErrFailedToAddCustomer = errors.New("failed to add the customer to the repository")
    // ErrUpdateCustomer is returned when the customer cannot be updated in the repository.
    ErrUpdateCustomer = errors.New("failed to update the customer in the repository")
)
//  CustomerRepository is an interface that defines the rules around the customer repository
// Functions that must be implemented
type CustomerRepository interface {
    Get(uuid.UUID) (aggregate.Customer, error)
    Add(aggregate.Customer) error
    Update(aggregate.Customer) error
}

Next, we need to implement the actual business logic of the interface. We will start with memory storage. At the end of this article, we'll learn how to change it to a MongoDB storage scheme without breaking anything else.

I like to keep each implementation in its directory just to make it easier for new developers on the team to find the right code location. Let's create a folder called memory to indicate that the warehouse uses memory as storage.

Another way is to create memory in the customer package Go, but I find that in a larger system, it can quickly become chaotic

mkdir memory
touch memory/memory.go

Let's start with memory With the correct structure set in the go file, we want to create a structure that implements the customerreposition interface, and don't forget to create a factory function for a new warehouse.

// The memory package is implemented in the memory of the customer warehouse
package memory

import (
    "sync"

    "github.com/google/uuid"
    "github.com/percybolmer/ddd-go/aggregate"
)

// MemoryRepository implements the customerreposition interface
type MemoryRepository struct {
    customers map[uuid.UUID]aggregate.Customer
    sync.Mutex
}

// New is a factory function used to generate a new customer warehouse
func New() *MemoryRepository {
    return &MemoryRepository{
        customers: make(map[uuid.UUID]aggregate.Customer),
    }
}

// Get find Customer by ID
func (mr *MemoryRepository) Get(uuid.UUID) (aggregate.Customer, error) {
    return aggregate.Customer{}, nil
}

// Add adds a new Customer to the repository
func (mr *MemoryRepository) Add(aggregate.Customer) error {
    return nil
}

// Update will replace the existing Customer information with the new Customer information
func (mr *MemoryRepository) Update(aggregate.Customer) error {
    return nil
}

We need to add a method to retrieve information from the Customer aggregation, such as the ID from the root entity. So we should update the aggregation with a function to get the ID and a function to change the name.

// GetID returns the root entity ID of the customer
func (c *Customer) GetID() uuid.UUID {
    return c.Person.ID
}
// SetName change the name of the customer
func (c *Customer) SetName(name string) {
    c.Person.Name = name
}

Let's add some very basic functionality to the memory warehouse so that it works as expected.

// The memory package is the memory implementation of the customer warehouse
package memory

import (
    "fmt"
    "sync"

    "github.com/google/uuid"
    "github.com/percybolmer/ddd-go/aggregate"
    "github.com/percybolmer/ddd-go/domain/customer"
)

// MemoryRepository implements the customerreposition interface
type MemoryRepository struct {
    customers map[uuid.UUID]aggregate.Customer
    sync.Mutex
}

// New is a factory function that generates a new customer repository
func New() *MemoryRepository {
    return &MemoryRepository{
        customers: make(map[uuid.UUID]aggregate.Customer),
    }
}

// Get find 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 adds a new Customer to the repository
func (mr *MemoryRepository) Add(c aggregate.Customer) error {
    if mr.customers == nil {
    // Security check if the Customer is not created, it should not happen when using the factory, but you never know
        mr.Lock()
        mr.customers = make(map[uuid.UUID]aggregate.Customer)
        mr.Unlock()
    }
    // Make sure the Customer is not in the warehouse
    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 the existing Customer information with the new Customer information
func (mr *MemoryRepository) Update(c aggregate.Customer) error {
    // Ensure that the 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
}

As before, we should add unit tests to the code. I want to point out how good the warehouse model is from the perspective of testing. In unit tests, it is easy to replace some of the logic with a warehouse created only for the test, which makes it easier to issue known errors in the 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 simulated Customer to add to the repository
    cust, err := aggregate.NewCustomer("Percy")
    if err != nil {
        t.Fatal(err)
    }
    id := cust.GetID()
    // Create a warehouse to use and add some test data for testing
    // Skip factory
    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())
            }
        })
    }
}

Good. We have our first warehouse. Remember to keep warehouses relevant to their domain. In this case, the warehouse only processes the Customer aggregation, which it should only do. Never let the repository be coupled to any other aggregation, we want to be loosely coupled.

So how do we deal with the logical flow of the hotel? We can't simply rely on the customer warehouse? At some point, we will begin to couple different warehouses and build a flow representing Hotel logic.

Entering Services is the last part we need to learn.

Services - connecting business logic

We have these entities, an aggregation, and a repository, but it's not like an application, is it? This is why we need the next component, Service.

Service will bind all loosely coupled warehouses into business logic that meets the needs of a specific domain. In a hotel application, we may have an Order service that links warehouses together to execute orders. Therefore, the service will have access to the customer repository and the product repository.

A Service typically contains all the warehouses needed to execute a business logic flow, such as Order, Api, or billing. You can even include another Service in one Service.

We will implement the Order service, which can then become part of the Tavern service. Let's create a new folder called services, which will store the services we implement. We first create a file called Order Go's file will hold OrderService, which we will use to process new orders in the hotel. We still lack some areas, so we'll just start with the customer repository, but we'll add more areas soon.

I want to start by creating a new Service Factory and demonstrate a very simple technique, which I learned from Jon Calhoun's web development book. We will create an alias for a function that accepts a Service pointer and modifies it, and then allows the use of variable parameters of these aliases. It's easy to change the behavior of the Service or replace the warehouse in this way.

// The service package contains all services that connect the warehouse to the business flow
package services

import (
    "github.com/percybolmer/ddd-go/domain/customer"
)

// OrderConfiguration is an alias for a function that will accept a pointer to OrderService and modify it
type OrderConfiguration func(os *OrderService) error

//OrderService is an implementation of OrderService
type OrderService struct {
    customers customer.CustomerRepository
}

// NewOrderService accepts a variable number of OrderConfiguration functions and returns a new OrderService
// Each OrderConfiguration is called in the order it is passed in
func NewOrderService(cfgs ...OrderConfiguration) (*OrderService, error) {
    // Create orderservice
    os := &OrderService{}
    // Apply all incoming Configurations
    for _, cfg := range cfgs {
        // Pass the service to the configuration function
        err := cfg(os)
        if err != nil {
            return nil, err
        }
    }
    return os, nil
}

See how we can accept a variable number of orderconfigurations in the factory method? This is a very neat way to allow dynamic factories and allow developers to configure the code structure, provided that the related functions have been implemented. This technique is useful for unit testing because you can replace some parts of the service with the required repository.

For smaller services, this approach seems a little complicated. I want to point out that in the example, we only use configurations to modify the warehouse, but this can also be used for internal settings and options. For smaller services, you can also create a simple factory function, such as accepting customer repository.

Let's create an OrderConfiguration that applies the customerreposition so that we can start creating the business logic of the Order.

// WithCustomerRepository applies the given customer warehouse to OrderService
func WithCustomerRepository(cr customer.CustomerRepository) OrderConfiguration {
    // Returns a function that matches the OrderConfiguration alias,
    // You need to return this so that the parent function can accept all the required parameters
    return func(os *OrderService) error {
        os.customers = cr
        return nil
    }
}

// WithMemoryCustomerRepository applies the memory customer repository to the OrderService
func WithMemoryCustomerRepository() OrderConfiguration {
    // Create a memory warehouse. If we need parameters, such as connection string, they can be entered here
    cr := memory.New()
    return WithCustomerRepository(cr)
}

Now, to use this, you can simply link all configurations when creating the service, so that we can easily replace components.

// Examples of memory used in development
NewOrderService(WithMemoryCustomerRepository())
// We can switch to MongoDB like this in the future
NewOrderService(WithMongoCustomerRepository())

Let's start adding functionality to the Order service so that customers can buy things in the hotel.

// CreateOrder links all warehouses together to create orders for customers
func (o *OrderService) CreateOrder(customerID uuid.UUID, productIDs []uuid.UUID) error {
    // Get customer
    c, err := o.customers.Get(customerID)
    if err != nil {
        return err
    }

    // For each product, we need a product repository
    return nil
}

Oh, our hotel doesn't have any products. You must know how to solve it? Let's implement more warehouses and apply them to the service by using OrderConfiguration.

Product Repository - the last part of the hotel system

You can refer to the implementation of customer Repository.