Implementing DDD patterns with Golang is very educational and a great way to refresh on these concepts, because you have to implement everything by yourself. Using frameworks that do “magical” things under the hood is often discouraged, even the use of DI containers, so you end up understanding the patterns to their core.
Minimising the use of external dependencies or frameworks gives you the opportunity to organise your code the way you consider best, and understand everything that’s happening in it at every step. On the other hand, it leaves you with no excuses if your code is messy or disorganised.
In this article we are going to cover some DDD patterns to clean up messy do-it-all services, separate responsibilities, improve testability and more.
Introducing: a Messy Service
We will present a service that, even though works correctly, is in desperate need of a refactor. This service is part of a made-up microservice that is the backend of a cooking app. These are some of the business requirements of the app:
- Users can create recipes which have an ID, a name and a description.
- Users can add ingredients to recipes.
- Ingredients have ID, name, description, quantity, measurement unit and calorie count.
- Ingredient quantity and calories must be more than 0.
- Ingredient measurement units must be grams, kilos, litres or millilitres.
- Names can’t be blank.
- Descriptions can’t be less than 10 characters.
- Recipes must have the total calorie count sum.
- Service must have an endpoint to create an empty recipe.
- Service must have an endpoint to add ingredients to a recipe.
- Persist in a POSTGRESQL database.
We will be using GORM as a database ORM for easier readability in this article. However as stated before, the more you avoid libraries, the better!
type recipe struct {
ID string `gorm:"primary_key"`
Name string
Description string
TotalCalories int
CreatedAt time.Time
DeletedAt time.Time
Ingredients []ingredient `gorm:"foreignKey:RecipeID"`
}
type ingredient struct {
ID string `gorm:"primary_key"`
Name string
Description string
Quantity float64
MeasurementUnit string
CalorieCount int
RecipeID string
CreatedAt time.Time
DeletedAt time.Time
}
var validMeasurementUnits = map[string]struct{}{
"liters": {},
"milliliters": {},
"grams": {},
"kilograms": {},
}
type MessyService struct {
db *gorm.DB
}
func NewMessyService(db *gorm.DB) *MessyService {
return &MessyService{db: db}
}
func (s *MessyService) CreateRecipe(req dto.CreateRecipeRequest) (dto.CreateRecipeResponse, error) {
// Run validations
if req.Name == "" {
return dto.CreateRecipeResponse{}, errors.NewApiError("recipe name cannot be blank", http.StatusBadRequest)
}
if len(req.Description) < 10 {
return dto.CreateRecipeResponse{}, errors.NewApiError("description is too short", http.StatusBadRequest)
}
recp := recipe{
ID: uuid.NewString(),
Name: req.Name,
Description: req.Description,
TotalCalories: 0,
}
if err := s.db.Create(&recp).Error; err != nil {
return dto.CreateRecipeResponse{}, errors.NewApiError("error saving recipe to db", http.StatusInternalServerError)
}
return dto.CreateRecipeResponse{ID: recp.ID, Name: recp.Name, Description: recp.Description}, nil
}
Few things to note so far:
We have the model for the entities, which also has persistence-related information, like CreatedAt and DeletedAt fields and gorm tags.
- The service holds a gorm db instance as a dependency to save and retrieve data from postgresql database.
- We have a map for the valid measurement units.
- The CreateRecipe method runs business validations, creates a db model with the data, persists it, and returns a response.
The AddIngredient method is a bit more interesting (a real service would probably make this endpoint a batch endpoint):
func (s *MessyService) AddIngredient(req dto.AddIngredientRequest) (dto.AddIngredientResponse, error) {
// Run business validations
if req.IngredientName == "" {
return dto.AddIngredientResponse{}, errors.NewApiError("ingredient name cannot be blank", http.StatusBadRequest)
}
if len(req.IngredientDescription) < 10 {
return dto.AddIngredientResponse{}, errors.NewApiError("description is too short", http.StatusBadRequest)
}
if _, ok := validMeasurementUnits[req.IngredientMeasurementUnit]; !ok {
return dto.AddIngredientResponse{}, errors.NewApiError("invalid measurement unit", http.StatusBadRequest)
}
if req.IngredientQuantity <= 0.0 {
return dto.AddIngredientResponse{}, errors.NewApiError("invalid quantity", http.StatusBadRequest)
}
if req.IngredientCalories < 0 {
return dto.AddIngredientResponse{}, errors.NewApiError("invalid calories", http.StatusBadRequest)
}
// We need to start a transaction, because we need to both create a new ingredient, and also
// update the recipe
tx := s.db.Begin()
// Create ingredient
ing := ingredient{
ID: uuid.NewString(),
Name: req.IngredientName,
Description: req.IngredientDescription,
Quantity: req.IngredientQuantity,
MeasurementUnit: req.IngredientMeasurementUnit,
CalorieCount: req.IngredientCalories,
RecipeID: req.RecipeID,
}
if err := tx.Create(&ing).Error; err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error saving to db", http.StatusInternalServerError)
}
// Retrieve recipe
var recp recipe
if err := tx.Preload("Ingredients").First(&recp, req.RecipeID); err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error reading from db", http.StatusInternalServerError)
}
recp.TotalCalories += req.IngredientCalories
if err := tx.Save(&recp); err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error updating recipe", http.StatusInternalServerError)
}
if err := tx.Commit(); err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError("error commiting tx", http.StatusInternalServerError)
}
return buildResponsee(recp, ing), nil
}
In this method we can start seeing bigger problems. First of all, the method is a bit too large, and it has a lot of responsibilities.
It needs to:
- Run validations on the input.
- Transform request objects into database objects.
- Handle a transaction to maintain consistency between the entities.
- Calculate business rules (calories)
- Transform database object into response object.
Because this method handles so many things, it is both difficult to read and difficult to test. If we were to write unit tests for this method, we would have test cases that test the business validations, mixed up with test cases that test the database interactions, and we would need to force a failure in every step to have all the code branches tested. This is a nightmare.
In the next sections we will be extracting code from this method into other files, using some principles from DDD and Hexagonal Architecture
Introducing: The Domain Layer
The domain of an application is the area that it will be operating in, containing all the business concepts, entities, rules, and a language with specific keywords that all the stakeholders share so they communicate effectively.
In the beginning of the development, the development team, along with business stakeholders, and domain experts should meet up and create a domain model, which is an abstraction of the needed components to handle a domain. We are assuming that the meeting already happened in the past for our app.
In the case of our application, the language’s keywords would be recipe, ingredient, quantity, measurement unit, etc. and the corresponding understanding of those words in the context of the app.
Some business rules would be: names can’t be blank, descriptions must be longer than 10 characters, and recipes must have the total sum of calories of its ingredients.
I’m sure you already have an idea of the code we will be extracting from the service. All the code related to business rules, validations and entities. We need to focus strictly on these concepts and abstract our minds about other concerns like persistence, serialisation, communication with external services, etc. Just business.
Domain Entities and Value Objects
In the domain layer, an entity is an object that has an unique identity within the domain model, (like an ID), and even if its attributes change over time, their identity remains. Equality between two entities consists of them having the same identifier. Our entities are easy to identify: recipe and ingredient.
A value object, on the other hand, is an object that represents a descriptive aspect of the domain, such as a money object or a date range, and has no identity. Equality between two value objects consists of them having the same values, even if they are different objects in memory. They are immutable.
Value objects are a bit trickier to identify, but in our case, there is a concept that jumps out. We can combine both the quantity and measurement unit into the same struct called Amount. Because two Amount structs with the same values are basically the same concept, and it doesn’t make sense for them to change state (just create another Amount).
type MeasurementUnit = string
const (
Kilo MeasurementUnit = "kilo"
Gram MeasurementUnit = "gram"
Liter MeasurementUnit = "liter"
Milliliter MeasurementUnit = "milliliter"
)
type Amount struct {
Quantity float64
MeasurementUnit MeasurementUnit
}
type Ingredient struct {
ID string
Name string
Description string
CalorieCount int
Amount Amount
}
type Recipe struct {
ID string
Name string
Description string
TotalCalories int
Ingredients []Ingredient
}
Things to note:
- We formally introduce the concept of MeasurementUnit, because it is part of the domain model and language.
- We don’t have any gorm tags or any kind of persistence details. We only have the fields business cares about.
- We also don’t have json tags or any presentation/serialisation details.
Domain Invariants
Invariants are generally business rules/enforcements/requirements that you impose to maintain the integrity of an object at any given time. That means they have to be enforced every time an object is created and every time it is modified.
That makes our struct constructors a good place to enforce invariants at creation time. Let’s define our constructors.
func NewAmount(quantity float64, unit MeasurementUnit) (Amount, error) {
if quantity <= 0 {
return Amount{}, errors.New("quantity must be > 0")
}
if unit != Kilo && unit != Gram && unit != Liter && unit != Milliliter {
return Amount{}, errors.New("invalid measurement unit")
}
return Amount{Quantity: quantity, MeasurementUnit: unit}, nil
}
func NewIngredient(name, description string, calories int, amount Amount) (Ingredient, error) {
if name == "" {
return Ingredient{}, errors.New("names can't be blank")
}
if len(description) < 10 {
return Ingredient{}, errors.New("description is too short")
}
if calories < 0 {
return Ingredient{}, errors.New("calories must be 0 or greater")
}
return Ingredient{ID: uuid.NewString(), Name: name, Description: description, Amount: amount}, nil
}
You can see in this way how validations are a lot more organised. Each domain entity is responsible for validating its own fields, and if they receive another entity as input, they can always trust that it is a valid entity and its invariants are enforced (as long as you always call constructors). We skip the constructor for recipe, but it should be very similar to the ingredients one.
We also enforce that the invariant recipes must have the total sum of calories of its ingredients. Because recipes at creation time have 0 ingredients, then, the calories are 0.
We only have one more business feature to implement in the domain layer, the add ingredient method. Which is a lot more simple now that we divided concerns and responsibilities:
func (r *Recipe) AddIngredient(ingredient Ingredient) {
r.Ingredients = append(r.Ingredients, ingredient)
r.TotalCalories += ingredient.CalorieCount
}
We enforce the invariant about the calories to always have valid entities.
We have successfully extracted all business concepts and rules into their own package. Modifications to business rules in the future will be a lot easier this way. It is also a lot easier to test them, because they are isolated from persistence and presentation concerns, and free from any external dependencies.
Messy Service 2.0
Let’s see how our service is refactored with a domain layer:
func (s *Service) CreateRecipe(req dto.CreateRecipeRequest) (dto.CreateRecipeResponse, error) {
recp, err := domain.NewRecipe(req.Name, req.Description)
if err != nil {
return dto.CreateRecipeResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
dbRecipe := toDBModelRecipe(recp)
if dbErr := s.db.Create(&dbRecipe).Error; dbErr != nil {
return dto.CreateRecipeResponse{}, errors.NewApiError("error saving recipe to db", http.StatusInternalServerError)
}
return dto.CreateRecipeResponse{ID: recp.ID, Name: recp.Name, Description: recp.Description}, nil
}
Now we need a helper function to transform from a domain struct to a database model struct. This is a good sign: it represents a separation of concerns between the business entities and in what way, shape and form they are stored:
func (s *Service) AddIngredient(req dto.AddIngredientRequest) (dto.AddIngredientResponse, error) {
domainAmount, err := domain.NewAmount(req.IngredientQuantity, req.IngredientMeasurementUnit)
if err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
domainIngredient, err := domain.NewIngredient(req.IngredientName, req.IngredientDescription, req.IngredientCalories, domainAmount)
if err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
tx := s.db.Begin()
dbIngredient := toDBModelIngredient(domainIngredient, req.RecipeID)
if err := tx.Create(&dbIngredient).Error; err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error saving to db", http.StatusInternalServerError)
}
// Retrieve recipe
var dbRecipe recipe
if err := tx.Preload("Ingredients").First(&dbRecipe, req.RecipeID); err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error reading from db", http.StatusInternalServerError)
}
domainRecipe := fromDbModel(dbRecipe)
domainRecipe.AddIngredient(domainIngredient)
dbRecipe = toDBModelRecipe(domainRecipe)
if err := tx.Save(&dbRecipe); err != nil {
tx.Rollback()
return dto.AddIngredientResponse{}, errors.NewApiError("error updating recipe", http.StatusInternalServerError)
}
if err := tx.Commit(); err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError("error commiting tx", http.StatusInternalServerError)
}
return buildResponse(domainRecipe, domainIngredient), nil
}
We managed to remove all business validations from the service, but it is still quite messy with all the things about the database transaction and translation between domain and database models.
Introducing: The Repository Pattern
The repository is a design pattern that mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects. The main goal of the repository pattern is to decouple the business logic of an application from the details of how data is stored and retrieved, by providing a uniform API for interacting with the data store.
It typically consists of a repository interface that defines the methods for interacting with the data store, and one or more concrete repository classes that implement the interface and provide the specific implementation for a particular data storage system.
The service should work with the repository interface like it’s an in-memory storage for the objects and shouldn’t care about what’s happening behind the curtain.
There are many advantages to using it:
- Improved testability: By abstracting the data access logic into a separate component, the repository pattern makes it easier to write unit tests for the business logic. You can mock the repository interface and verify that the business logic is calling the repository methods as expected.
- Enhanced flexibility: The repository pattern allows you to easily switch between different data storage systems or configurations, depending on the needs of your application. For example, you could use a different concrete repository implementation for testing purposes, or to switch from a database to a file system or in-memory data structure.
- Better separation of concerns: The repository pattern promotes a separation of concerns between the business logic of the application and the data access logic, which can make the code easier to understand and maintain.
- Reusable code: By encapsulating the data access logic in a separate component, the repository pattern allows you to reuse this code across multiple parts of your application, or even in different applications.
- Improved performance: By centralising the data access logic in a single component, the repository pattern can make it easier to optimise the performance of your application by batching or caching data access operations.
First we need to define an interface with the methods that the repository will have to provide for the service to be able to do the same work.
It is a good practice to define the interface in the same file as the service that depends on it, for improved readability, and also to specify which exact methods the service needs.
If we only had the create recipe method, we could have something like this:
type repository interface {
CreateRecipe(recipe domain.Recipe) error
}
type Service struct {
repository repository
}
func NewService(repository repository) *Service {
return &Service{repository: repository}
}
func (s *Service) CreateRecipe(req dto.CreateRecipeRequest) (dto.CreateRecipeResponse, error) {
recp, err := domain.NewRecipe(req.Name, req.Description)
if err != nil {
return dto.CreateRecipeResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
if dbErr := s.repository.CreateRecipe(recp); dbErr != nil {
return dto.CreateRecipeResponse{}, errors.NewApiError("error saving recipe to db", http.StatusInternalServerError)
}
return dto.CreateRecipeResponse{ID: recp.ID, Name: recp.Name, Description: recp.Description}, nil
}
We have successfully decoupled the persistence details from the service. We could switch databases to a MongoDB later, and we won’t have to do anything from the service side. Just switch implementations at service start-up.
We also were able to move our database struct definitions from the service to the repository implementation, and also the mapping functions from domain to database model and vice versa.
But what about the AddIngredient method?
The Transaction Problem
It is not as easy to replace gorm methods with repository methods, because we have the transaction in place to ensure the consistency of the updates.
What if we have it like this?:
type repository interface {
CreateTransaction() (string, error)
CommitTransaction(transactionID string) error
RollbackTransaction(transactionID string) error
CreateRecipe(recipe domain.Recipe, transactionID string) error
UpdateRecipe(recipe domain.Recipe, transactionID string) error
CreateIngredient(ingredient domain.Ingredient, transactionID string) error
}
We could have some methods to create, commit and rollback transactions, and have the other methods receive the transaction. That way we can replicate the same behaviour!
But.. we would be undermining our own objective, which was to abstract the service from any persistence details, by making the service handle the database transaction.
How can we solve this?
The Aggregate Pattern
A DDD aggregate is a cluster of domain objects that can be treated as a single unit. An example may be an order and its products, these will be separate objects, but it’s useful to treat the order (together with its products) as a single aggregate. An aggregate will have one of its component objects be the aggregate root (in this example’s case, it would be the order) Any references from outside the aggregate should only go to the aggregate root. The root can thus ensure the integrity of the aggregate as a whole, and will hold the invariant implementations. You should have a repository per aggregate root.
Aggregates are the basic element of transfer of data storage – you request to load or save whole aggregates. You should never access or modify an aggregate root’s child directly. This way there is no need for transactions to ensure the aggregate root and its children are modified as a unit.
We didn’t realise, but in the domain section, we already created an aggregate. In our case, we can treat the recipe as the aggregate root, and the ingredients will be its children. Following the pattern, we should treat everything as a single unit and not modify or save ingredients independently. This simplifies our repository:
type repository interface {
GetRecipe(recipeID string) (domain.Recipe, error)
CreateRecipe(recipe domain.Recipe) error
UpdateRecipe(recipe domain.Recipe) error
}
And our AddIngredient method:
func (s *Service) AddIngredient(req dto.AddIngredientRequest) (dto.AddIngredientResponse, error) {
domainAmount, err := domain.NewAmount(req.IngredientQuantity, req.IngredientMeasurementUnit)
if err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
domainIngredient, err := domain.NewIngredient(req.IngredientName, req.IngredientDescription, req.IngredientCalories, domainAmount)
if err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusBadRequest)
}
domainRecipe, err := s.repository.GetRecipe(req.RecipeID)
if err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusInternalServerError)
}
domainRecipe.AddIngredient(domainIngredient)
if err := s.repository.UpdateRecipe(domainRecipe); err != nil {
return dto.AddIngredientResponse{}, errors.NewApiError(err.Error(), http.StatusInternalServerError)
}
return buildResponse(domainRecipe, domainIngredient), nil
}
By treating the recipe as an aggregate, we just call UpdateRecipe with the new ingredient and modifications and the repository layer should handle the persistence details. We don’t care if there is a postgresql database behind the curtain. If we had something like a No-SQL database, then it makes even more sense because usually the whole aggregate is serialised as a value of the key of the aggregate root.
This makes sense in this case, because a recipe will never have hundreds or thousands of ingredients. If an aggregate root could have that many children, then it would be time to reconsider the aggregate. That is why every case should be analysed taking into consideration the details of the domain it lives in.
But wait!. What if the recipe is modified by someone else in between the GetRecipe and the UpdateRecipe calls?. This is a very valid concern, but easily solved by implementing optimistic locking.
Last thing to do is to write the actual repository implementation, that will hold the database model structs, the methods, the transactions, and everything else.
The Composition Root
To wire everything up, we use the composition root pattern. In the case of a go web server, it usually is in the cmd/main.go file. Here we manually create components and pass it over to other components that depend on them. A quick example:
func NewServer() *Server {
postgresDB := db.InitPostgresDB(cfg)
repo := repository.NewPostgresRepository(postgresDB)
service := service.NewService(repo)
handler := handler.NewHandler(service)
gin.SetMode(gin.ReleaseMode)
ginEngine := gin.New()
ginEngine.POST("/recipe", handler.CreateRecipe())
return &Server{
svr: &http.Server{
Addr: ":8080",
Handler: ginEngine,
},
}
}
The db package would be your database driver. The repository package is the actual repository implementation that we talked about before.
If you notice, the domain is nowhere to be found here. That’s good!. The domain, as we stated before, should need no initialisation of any kind, and shouldn’t depend on anything else, so we don’t have it in our composition root.
In Conclusion
These techniques can’t, and shouldn’t, be applied in every case, but they are useful refactoring patterns to improve the separation of concerns in your code.
DDD is best applicable when the domain is characterised by a high level of complexity, with many interconnected concepts and relationships. Also, it relies on the input and expertise of domain experts, so if there are no domain experts available or if the domain is not well understood, it may not be possible to effectively apply DDD.
If you have the time and opportunity to apply these concepts, they will reward you greatly in the future. I hope you found it useful and it inspires you in your next developments!