In the first part of this book, we learned about the theory behind domain-driven design (DDD) and looked at isolated examples of how we might implement each idea or pattern. In Part 2 of this book, we are going to build real-world applications together that will help cement the ideas and give you example projects to reference in the future.
We will start by building a domain-driven monolithic application (after defining what a monolithic application is) from scratch. We will then discuss how you might apply DDD principles to an existing application that was not created using DDD from the beginning.
By the end of the chapter, you will be able to understand the following topics:
We’ll get started by looking at what we mean by a monolithic application. But before that, let’s go through the technical requirements of the chapter.
In this chapter, we will write a large amount of Golang code. To be able to follow along, you will need the following:
A friendly warning
The application we are going to create in this chapter is intended for demonstration only and to really highlight how to work in the domain-driven design style. It is not production ready, and we will be skipping lots of best practices, such as testing and documentation. These are critically important but beyond the scope of this book.However, please see Chapter 8 (P-Italics) for some insight on how testing and DDD can be complimentary
A monolithic application, or monolith, is likely a term you have heard before, as it is probably the most popular pattern for developing an enterprise application. We call it a monolithic application if all the different components of the system are encapsulated into a single unit – for example, if the user interface, several domains, and infrastructure services are combined into a single deployable unit. The following figure illustrates this:
Figure 5.1 – Multiple services packed into a single application
Monolithic applications remain popular because of the following reasons:
However, there are also some major downsides. These downsides tend to appear as the application grows in complexity and/or scale:
In the next section, we will build a simple monolithic application using domain-driven design principles. Let’s start by outlining the business for which we will build a system.
In this section, we will outline a scenario using a fictitious company. Domain-driven design is all about solving business invariants in a specific context, and I hope this example will help reinforce that.
CoffeeCo is a national coffee shop chain. They experienced rapid growth in the last year and have opened 50 new stores. Each store sells coffee and coffee-related accessories, as well as store-specific drinks. Stores often have individual offers, but national marketing campaigns are often run, which influence the price of an item too.
CoffeeCo recently launched a loyalty program called CoffeeBux, which allows customers to get 1 free drink for every 10 they purchase. It doesn’t matter which store they purchase a drink at or which they redeem it at.
CoffeeCo has been thinking of launching an online store. They are also considering a monthly subscription that allows purchasers to get unlimited coffee every month, as well as a discount on other drinks. Now that we understand the business domain, we can start to explore how we can build systems to help CoffeeCo achieve its goals!
We had a domain modeling session with the domain experts, which included employees at the coffee shop, people from the head office, and suppliers. In this session, we have identified the following ubiquitous language and definitions that we should keep in mind as we develop our system:
During the domain modeling session, we identified the following domains:
We will revisit each of these as we build our system.
We spoke about what would be a good minimum viable product (MVP) for the new system that we will create. The domain experts felt that the following features need to be in scope:
Now that we understand what our business does, let’s begin to build a domain-driven system to satisfy all the requirements.
Let’s start by initializing a new Golang project:
Figure 5.2 – Project creation screen in GoLand
Figure 5.3 – Our project structure so far
The internal folder is a special folder in Golang. Anything within the internal directory cannot be imported by other projects. This is a great fit for DDD as we do not want our domain code to be part of our public API. To automatically enforce this, we will be putting all our domain code inside this internal folder.
A coffee lover is most certainly an entity. This is because we want a coffee lover to be defined by their identity; whenever we are talking about a coffee lover and adding CoffeeBux to their loyalty account, there should be no doubt as to which coffee lover we are applying these.
Let’s create the coffeelover.go file inside the internal folder as follows:
Figure 5.4 – The coffeelover.go file
This will make it accessible to our entire domain code.
package coffeeco
import "github.com/google/uuid"
type CoffeeLover struct {
ID uuid.UUID
FirstName string
LastName string
EmailAddress string
}
You’ll notice that we have added some entity attributes (FirstName, LastName, and EmailAddress). This was after consulting the domain experts and understanding what information we need to store about coffee lovers. Domain-driven design is about constant communication with your stakeholders and it’s essential you do this.
Figure 5.5 – Adding a store domain
package store
import "github.com/google/uuid"
type Store struct {
ID uuid.UUID
Location string
}
Notice we have decided to make the store an entity again. Again, this is due to the fact that when we reference a store, it’s really important that we can easily identify which one we are talking about.
This is a good start but a lot is missing that we have not defined yet. Each store sells coffee and coffee-related accessories, as well as store-specific drinks. We therefore need to define a product.
Defining products is our first real challenge. Should it be an entity or a value object? I think it could be either. Let’s go back to our questions from Chapter 3:
We can answer yes to all of these questions. Furthermore, as we mentioned in Chapter 3, it is better to treat something as a value object and then upgrade it to an entity later, as it’s a safer construct to deal with. Therefore, for now, we will treat the product as a value object. Let’s go ahead and implement it:
Figure 5.6 – The product.go file
package coffeeco
import "github.com/Rhymond/go-money"
type Product struct {
ItemName string
BasePrice money.Money
}
We added the base price after consulting our domain experts. This is the language they use to refer to the non-offer price of a product. We should add this to our ubiquitous language definitions.
package store
import (
"github.com/google/uuid"
coffeeco "coffeeco/internal"
)
type Store struct {
ID uuid.UUID
Location string
ProductsForSale []coffeeco.Product
}
store.go looks pretty good. We now need to think of how we would like to model a purchase. This would be another situation we would speak to our domain experts about to understand the language they use to describe a customer buying coffee and whether there is anything surprising that we need to account for.
Figure 5.7 – Creating purchase.go
package purchase
import (
"github.com/Rhymond/go-money"
"github.com/google/uuid"
coffeeco "coffeeco/internal"
"coffeeco/internal/store"
)
type Purchase struct {
id uuid.UUID
Store store.Store
ProductsToPurchase []coffeeco.Product
total money.Money
PaymentMeans payment.Means
timeOfPurchase time.Time
}
Our Purchase type has its own ID and should be an entity; this makes sense. If a customer ever wants a refund on an item, we will need to be able to reference a specific transaction.
We, therefore, need to make a payment domain. Let’s create payment/means.go:
Figure 5.8 – Our project structure so far
package payment
type Means string
const (
MEANS_CARD = "card"
MEANS_CASH = "cash"
MEANS_COFFEEBUX = "coffeebux"
)
type CardDetails struct {
cardToken string
}
We have used a type alias here to represent payment means. We have also created a struct to represent CardDetails. This is not how card payments work, but in our simple example, we will assume we receive a token representing a card at the time of purchase, and that is what we will charge.
We have made some constants here to define cash and CoffeeBux payments too. This is a little pre-emptive, but we know we will need them shortly, so I see no harm.
type Purchase struct {
id uuid.UUID
Store store.Store
ProductsToPurchase []coffeeco.Product
total money.Money
PaymentMeans payment.Means
timeOfPurchase time.Time
CardToken *string
}
Let’s make loyalty/coffeebux.go:
Figure 5.9 – Our project structure so far
package loyalty
import (
"github.com/google/uuid"
coffeeco "coffeeco/internal"
"coffeeco/internal/store"
)
type CoffeeBux struct {
ID uuid.UUID
store store.Store
coffeeLover coffeeco.CoffeeLover
FreeDrinksAvailable int
RemainingDrinkPurchasesUntilFreeDrink int
}
We reference a lot of other entities here. It really highlights why companies like having loyalty schemes; look at how much data we can gather!
We finally have all our domain models defined and we are ready to take our first pass at creating a service. A purchase is a good fit for service because of the following:
To program as defensively as possible, we are going to define a validateAndEnrich function in product.go. This will help us keep our service as thin as possible. Remember, we should always be trying to push down as much logic as possible into our domain objects:
func (p *Purchase) validateAndEnrich() error {
if len(p.ProductsToPurchase) == 0 {
return errors.New("purchase must consist of at least one product")
}
p.total = *money.New(0, "USD")
for _, v := range p.ProductsToPurchase {
newTotal, _ := p.total.Add(&v.BasePrice)
p.total = *newTotal
}
if p.total.IsZero() {
return errors.New("likely mistake; purchase should never be 0. Please validate")
}
p.id = uuid.New()
p.timeOfPurchase = time.Now()
return nil
}
In this code block, notice the following:
type CardChargeService interface {
ChargeCard(ctx context.Context, amount money.Money, cardToken string) error
}
type Service struct {
cardService CardChargeService
purchaseRepo Repository
}
func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase) error {
if err := purchase.validateAndEnrich(); err != nil {
return err
}
switch purchase.PaymentMeans {
case payment.MEANS_CARD:
if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken); err != nil {
return errors.New("card charge failed, cancelling purchase")
}
case payment.MEANS_CASH:
// TODO: For the reader to add :)
default:
return errors.New("unknown payment type")
}
if err := s.purchaseRepo.Store(ctx, *purchase); err != nil {
return errors.New("failed to store purchase")
}
return nil
}
The service is small. We call our validateAndEnrich function to mutate the purchase object for us to add some necessary values. We could also have made Purchase both a value object and an entity, which would have meant we did not need to mutate.
Let’s imagine that your team has split into two halves. One half is working on payments, and the other on implementing purchases. We could meet with the payment team and agree on a contract for what CardService will look like (in Go, we call these contracts interfaces), and then we can continue with our implementations at separate paces. This is a really powerful pattern so use it often when working with other teams!
Finally, if all goes well, we call PurchaseRepo to store our new purchase.
We define repository.go inside the purchase package:
Figure 5.10 – repository.go added to the purchase folder
And for now, it’s just an interface:
package purchase import "context" type Repository interface { Store(ctx context.Context, purchase Purchase) error }
Again, defining this as an interface is a good idea. As a team, we can now have a discussion about which database might be the best for this project and it has not slowed down development. We could satisfy this interface with any number of different databases.
We now have a basic outline of what our service will look like. We need to add an infrastructure service for payment and implementation of our repository layer. Let’s do both now.
After a discussion with the team, we have opted to use Mongo, a document database. The reason the team selected Mongo is that they have good experience with running it, and given that products and payments have flexible metadata, we think it will be a good fit.
From a development perspective, it also means we don’t need to write any database migration scripts.
So, let’s first connect to Mongo and then implement a product repository:
type MongoRepository struct {
purchases *mongo.Collection
}
func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
if err != nil {
return nil, fmt.Errorf("failed to create a mongo client: %w", err)
}
purchases := client.Database("coffeeco").Collection("purchases")
return &MongoRepository{
purchases: purchases,
}, nil
}
import (
"context"
"fmt"
"time"
"github.com/Rhymond/go-money"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
coffeeco "coffeeco/internal"
"coffeeco/internal/payment"
"coffeeco/internal/store"
)
func (mr *MongoRepository) Store(ctx context.Context, purchase Purchase) error {
mongoP := New(purchase)
_, err := mr.purchases.InsertOne(ctx, mongoP)
if err != nil {
return fmt.Errorf("failed to persist purchase: %w", err)
}
return nil
}
type mongoPurchase struct {
id uuid.UUID
store store.Store
productsToPurchase []coffeeco.Product
total money.Money
paymentMeans payment.Means
timeOfPurchase time.Time
cardToken *string
}
func toMongoPurchase(p Purchase) mongoPurchase {
return mongoPurchase{
id: p.id,
store: p.Store,
productsToPurchase: p.ProductsToPurchase,
total: p.total,
paymentMeans: p.PaymentMeans,
timeOfPurchase: p.timeOfPurchase,
cardToken: p.cardToken,
}
}
The reason we do this is to decouple our purchase aggregate from the Mongo implementation. We should also decouple all the other domain models from the database models, but I will leave that as an exercise for you.
For now, that’s all we need. Our repository layer is very simple and lightweight, which is a sign that DDD is really helping us here.
For our payment service, we are going to use Stripe. Like our Mongo repository, we are going to decouple ourselves from Stripe as much as possible, as it is not part of our domain, and it is a tool that the company may change its mind on in the future. This can be particularly true for payment services if a cheaper option comes along.
So, here’s how we connect Stripe from our service:
Figure 5.11 – stripe.go added to the payment folder
package payment
import (
"context"
"errors"
"fmt"
"github.com/Rhymond/go-money"
"github.com/stripe/stripe-go/v73"
"github.com/stripe/stripe-go/v73/charge"
"github.com/stripe/stripe-go/v73/client"
)
type StripeService struct {
stripeClient *client.API
}
func NewStripeService(apiKey string) (*StripeService, error) {
if apiKey == "" {
return nil, errors.New("API key cannot be nil ")
}
sc := &client.API{}
sc.Init(apiKey, nil)
return &StripeService{stripeClient: sc}, nil
}
Notice how we have imported the official Stripe package. You will need to add this to your go.mod.
type CardChargeService interface {
ChargeCard(ctx context.Context, amount money.Money, cardToken string) error
}
We, therefore, implement that function on our StripeService struct:
func (s StripeService) ChargeCard(ctx context.Context, amount money.Money, cardToken string) error { params := &stripe.ChargeParams{ Amount: stripe.Int64(amount.Amount()), Currency: stripe.String(string(stripe.CurrencyUSD)), Source: &stripe.PaymentSourceSourceParams{Token: stripe.String(cardToken)}, } _, err := charge.New(params) if err != nil { return fmt.Errorf("failed to create a charge:%w", err) } return nil }
This is near enough a copy and paste from the Stripe documentation (which is excellent). You can read more about creating charges in Stripe here: https://stripe.com/docs/api/charges/create?lang=go.
Challenge
See whether you can implement a different CardChargeService, perhaps using Square: https://developer.squareup.com/gb/en/online-payment-apis.
Now that we have done that, let’s go back to our business requirements!
Our system is looking good! It is modular and easy to extend…so let’s extend it.
As of now, we haven’t satisfied all our business requirements. The requirements state that coffee lovers should get a coffee free after they have purchased 10 already. This means we need to track the number of purchases and also allow customers to pay with CoffeeBux. Let’s design an entity to help us do this:
func (c *CoffeeBux) AddStamp() {
if c.RemainingDrinkPurchasesUntilFreeDrink == 1 {
c.RemainingDrinkPurchasesUntilFreeDrink = 10
c.FreeDrinksAvailable += 1
} else {
c.RemainingDrinkPurchasesUntilFreeDrink--
}
}
This code checks whether we need to increment our free drink count and reset our purchased drinks counter. Otherwise, we just add a virtual stamp.
func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error {
if err := purchase.validateAndEnrich(); err != nil {
return err
}
switch purchase.PaymentMeans {
case payment.MEANS_CARD:
if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken); err != nil {
return errors.New("card charge failed, cancelling purchase")
}
case payment.MEANS_CASH:
// For the reader to add :)
default:
return errors.New("unknown payment type")
}
if err := s.purchaseRepo.Store(ctx, *purchase); err != nil {
return errors.New("failed to store purchase")
}
if coffeeBuxCard != nil {
coffeeBuxCard.AddStamp()
}
return nil
}
Here, we have changed the signature of CompletePurchase to include CoffeeBuxCard. Notice how it’s a pointer. This is because a customer is under no obligation to present a loyalty card and therefore, it can be nil.
At the bottom of our function, after a user has paid and they have persisted the purchase successfully, we add a stamp to their loyalty card. Notice how easy it was to add and how easy it is to follow our code?
const (
MEANS_CARD = "card"
MEANS_CASH = "cash"
MEANS_COFFEEBUX = "coffeebux"
)
func (c *CoffeeBux) Pay(ctx context.Context, purchases []purchase.Purchase) error {
lp := len(purchases)
if lp == 0 {
return errors.New("nothing to buy")
}
if c.FreeDrinksAvailable < lp {
return fmt.Errorf("not enough coffeeBux to cover entire purchase. Have %d, need %d", len(purchases), c.FreeDrinksAvailable)
}
c.FreeDrinksAvailable = c.FreeDrinksAvailable - lp
return nil
}
In this code block, we program defensively and ensure that purchases is not empty. We then check there are enough free drinks to accommodate the entire purchase.
Note
We have made an assumption here that we should validate with the domain experts – it might be that they want to allow partial redemption of a purchase against a loyalty card. If that is so, our implementation is wrong, and we’d need to change it.
After this, we simply remove the necessary amount of free drinks from the CoffeeBux card.
We now have everything we need to accept payment in CoffeeBux, so let’s go ahead and add it to purchase.go:
func (s Service) CompletePurchase(ctx context.Context, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error { if err := purchase.validateAndEnrich(); err != nil { return err } switch purchase.PaymentMeans { case payment.MEANS_CARD: if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken); err != nil { return errors.New("card charge failed, cancelling purchase") } case payment.MEANS_CASH: // For the reader to add :) case payment.MEANS_COFFEEBUX: if err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase); err != nil { return fmt.Errorf("failed to charge loyalty card: %w", err) } default: return errors.New("unknown payment type") } if err := s.purchaseRepo.Store(ctx, *purchase); err != nil { return errors.New("failed to store purchase") } if coffeeBuxCard != nil { coffeeBuxCard.AddStamp() } return nil }
We have added the ability to pay with your CoffeeBux card. However, there is a potential bug here. Can you spot it?
Right now, if you pay with your CoffeeBux card, you still earn a loyalty stamp. This is something we need to consult our domain experts about to see whether this is the correct business invariant.
We have one final feature to add to fulfill the project brief, which is to add store-specific discounts. Before we propose a solution in the next section, try and think about how you might approach it. Even better, try and implement it!
First, we need to save store-specific discounts somewhere. We therefore need a repository layer:
Figure 5.12 – repository.go added to the store folder
package store
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var ErrNoDiscount = errors.New("no discount for store")
type Repository interface {
GetStoreDiscount(ctx context.Context, storeID uuid.UUID) (int, error)
}
type MongoRepository struct {
storeDiscounts *mongo.Collection
}
func NewMongoRepo(ctx context.Context, connectionString string) (*MongoRepository, error) {
client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectionString))
if err != nil {
return nil, fmt.Errorf("failed to create a mongo client: %w", err)
}
discounts := client.Database("coffeeco").Collection("store_discounts")
return &MongoRepository{
storeDiscounts: discounts,
}, nil
}
func (m MongoRepository) GetStoreDiscount(ctx context.Context, storeID uuid.UUID) (float32, error) {
var discount float32
if err := m.storeDiscounts.FindOne(ctx, bson.D{{"store_id", storeID.String()}}).Decode(&discount); err != nil {
if err == mongo.ErrNoDocuments {
// This error means your query did not match any documents.
return 0, ErrNoDiscount
}
return 0, fmt.Errorf("failed to find discount for store: %w", err)
}
return discount, nil
}
A lot of this code should look familiar from our previous repository layer. We may want to move some of the connection logic for a Mongo connection or pool of connections to a different package that we could share in the future.
When we use GetStoreDiscount, we check the error type; if it is ErrNoDocuments, we want to return a specific ErrNoDiscount error so that in the preceding layer, we know it’s not a real error.
If all goes well, we simply return our store discount.
type StoreService interface {
GetStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID) (float32, error)
}
type Service struct {
cardService CardChargeService
purchaseRepo Repository
storeService StoreService
}
func (s Service) CompletePurchase(ctx context.Context, storeID uuid.UUID, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error {
if err := purchase.validateAndEnrich(); err != nil {
return err
}
discount, err := s.storeService.GetStoreSpecificDiscount(ctx, storeID)
if err != nil && err != store.ErrNoDiscount {
return fmt.Errorf("failed to get discount: %w", err)
}
purchasePrice := purchase.total
if discount > 0 {
purchasePrice = *purchasePrice.Multiply(int64(100 - discount))
}
switch purchase.PaymentMeans {
case payment.MEANS_CARD:
if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken); err != nil {
return errors.New("card charge failed, cancelling purchase")
}
case payment.MEANS_CASH:
// For the reader to add :)
case payment.MEANS_COFFEEBUX:
if err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase); err != nil {
return fmt.Errorf("failed to charge loyalty card: %w", err)
}
default:
return errors.New("unknown payment type")
}
if err := s.purchaseRepo.Store(ctx, *purchase); err != nil {
return errors.New("failed to store purchase")
}
if coffeeBuxCard != nil {
coffeeBuxCard.AddStamp()
}
return nil
}
This is looking a little complex to read and isn’t using a lot of domain-specific language in our service layer, so let’s refactor a little:
func (s Service) CompletePurchase(ctx context.Context, storeID uuid.UUID, purchase *Purchase, coffeeBuxCard *loyalty.CoffeeBux) error { if err := purchase.validateAndEnrich(); err != nil { return err } if err := s.calculateStoreSpecificDiscount(ctx, storeID, purchase); err != nil { return err } switch purchase.PaymentMeans { case payment.MEANS_CARD: if err := s.cardService.ChargeCard(ctx, purchase.total, *purchase.cardToken); err != nil { return errors.New("card charge failed, cancelling purchase") } case payment.MEANS_CASH: // For the reader to add :) case payment.MEANS_COFFEEBUX: if err := coffeeBuxCard.Pay(ctx, purchase.ProductsToPurchase); err != nil { return fmt.Errorf("failed to charge loyatly card: %w", err) } default: return errors.New("unknown payment type") } if err := s.purchaseRepo.Store(ctx, *purchase); err != nil { return errors.New("failed to store purchase") } if coffeeBuxCard != nil { coffeeBuxCard.AddStamp() } return nil } func (s *Service) calculateStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID, purchase *Purchase) error { discount, err := s.storeService.GetStoreSpecificDiscount(ctx, storeID) if err != nil && err != store.ErrNoDiscount { return fmt.Errorf("failed to get discount: %w", err) } purchasePrice := purchase.total if discount > 0 { purchase.total = *purchasePrice.Multiply(int64(100 - discount)) } return nil }
Here, we have added a calculateStoreSpecificDiscount function and updated our service layer. It is much cleaner now, and it will be easier to speak to our domain experts about it.
type Service struct {
repo Repository
}
func (s Service) GetStoreSpecificDiscount(ctx context.Context, storeID uuid.UUID) (float32, error) {
dis, err := s.repo.GetStoreDiscount(ctx, storeID)
if err != nil {
return 0, err
}
return float32(dis), nil
}
We have now written an entire service using the domain-driven concept. The finished package structure is as follows:
Figure 5.13 – The final package structure for our application
I have provided main.go to make it runnable and a docker-compose file in the GitHub repo here: https://github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/tree/main/chapter5. This will enable you to run it and test it easily. In the README file, there are instructions on how to get it all started.
As an exercise, here are some features you might want to try and add to extend the service:
If you get them working, please feel free to create a PR into the example repo; I’d love to see them.
Applying DDD to an existing monolithic application
If you already work on a monolithic application, it is still worth trying to apply some of the patterns we have discussed throughout this book. My advice would be to start with building a strong relationship with the domain experts in your company. Together, you can start to build a ubiquitous language. If you start to reflect this in your code, you will notice that you will be able to start having much more meaningful and aligned conversations with them.
It might be that moving to repositories and domain objects is too much of a refactor. That’s okay. I would recommend that the infrastructure be the place where spending the time to decouple yourself from specific APIs (like we did with Stripe) is a valuable use of time. It will keep your business logic clearer and give the business more options when considering new providers in the future.
In this chapter, we got hands-on with Golang and built an entire application from scratch. We started by understanding the problem domain and building out a robust, ubiquitous language. We then built an application by splitting our application into domains, aggregates, repositories, services, and infrastructure services. Hopefully, you now see the true value of domain-driven design (if it was ever in doubt) and you are able to apply DDD principles to your own projects. In my experience, this is a highly desirable trait that is worth discussing when you are interviewing for new jobs.
In the next chapter, we will be looking at microservices, how they differ from monolithic applications, and what new things we need to consider when building them with domain-driven design in mind.
18.225.95.248