Golang and DDD domain modeling
Asked Answered
D

1

8

I've been studying domain-driven design lately and must say this type of architectural design triggers something in me. When I try to apply its concepts to my Go project I've encountered some obstacles. Following are some example methods, but I'm very uncertain which method to GO with.

Excerpt of the project structure:

├── api/
├── cmd/
├── internal/
|   ├── base/
|   |   ├── eid.go
|   |   ├── entity.go
|   |   └── value_object.go
|   ├── modules/
|   |   ├── realm/
|   |   |   ├── api/
|   |   |   ├── domain/
|   |   |   |   ├── realm/
|   |   |   |   |   ├── service/
|   |   |   |   |   ├── friendly_name.go
|   |   |   |   |   ├── realm.go
|   |   |   |   |   └── realm_test.go
|   |   |   |   └── other_subdomain/
|   |   |   └── repository/
|   |   |       ├── inmem/
|   |   |       └── postgres/

Common for all methods:

package realm // import "git.int.xxxx.no/go/xxxx/internal/modules/realm/domain/realm"

// base contains common elements used by all modules
import "git.int.xxxx.no/go/xxxx/internal/base"

Method #1:

type Realm struct {
   base.Entity

   FriendlyName FriendlyName
}

type CreateRealmParams struct {
    FriendlyName string
}

func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
   var err error
   var r = new(Realm)

   r.Entity = base.NewEntity(id)
   r.FriendlyName, err = NewFriendlyName(params.FriendlyName)

   return r, err
}

type FriendlyName struct {
    value string
}

var ErrInvalidFriendlyName = errors.New("invalid friendly name")

func (n FriendlyName) String() string { return n.value }

func NewFriendlyName(input string) (FriendlyName, error) {
    if input == "" {
        return ErrInvalidFriendlyName
    }
    // perhaps some regexp rule here...

    return FriendlyName{value: input}, nil
}

With this method I think there will be much repeated code in the long run, but at least the FriendlyName value-object is immutable as per DDD requirements and opens up for more methods to be attached.

Method #2:

type Realm struct {
    base.Entity

    FriendlyName string
}

type CreateRealmParams struct {
    FriendlyName string
}

func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
    var err error

    if err = validateFriendlyName(params.FriendlyName); err != nil {
        return nil, err
    }

    entity := base.NewEntity(id)

    return &Realm{
        Entity: entity,
        FriendlyName: params.FriendlyName,
    }, nil
}

This must be the most common one I've come across examples out there, except for the validation that very many examples lack.

Method #3:

type Realm struct {
    base.Entity

    friendlyName string
}

type CreateRealmParams struct {
    FriendlyName string
}

func CreateRealm(id base.EID, params *CreateRealmParams) (*Realm, error) {
    var err error

    if err = validateFriendlyName(friendlyName); err != nil {
        return nil, err
    }

    entity := base.NewEntity(id)

    return &Realm{
        Entity: entity,
        friendlyName: friendlyName,
    }, nil
}

func (r *Realm) FriendlyName() string { return r.friendlyName }
func (r *Realm) SetFriendlyName(input string) error {
    if err := validateFriendlyName(input); err != nil {
        return err
    }
    r.friendlyName = input
    return nil
}

Here the friendly name type is just a string, but immutable. This structure reminds me of Java code... When looking up a realm, should the repository-layer use the setter methods from the domain model to construct the realm aggregate? I tried with a DTO implementation placed in the same package (dto_sql.go) that encoded/decoded to/from the realm aggregate, but it kind of felt wrong having that concern placed in the domain package.

If you are facing the same issues as me, know about any other method or have anything to point out, I'll be very interested in hearing from you!

Dardanelles answered 25/2, 2019 at 20:7 Comment(7)
This seems like it shifts a whole lot of what should be compile-time static logic into run-time dynamic logic, which is somewhat at odds with the general goals of Go, is going to come at significant efficiency cost, and causes you a significant increase in complexity with no clear benefit. Personally I would suggest looking at the underlying goals of DDD and finding ways to fit that into the ideology of Go, rather than trying to recreate a stereotypical OOP DDD implementation in the non-OOP context of Go.Add
You should have a look at this There are also links to the example repo and the really great talk (youtube link)Inhospitality
@Tobias Theel: I've seen that lecture multiple times, but she has skipped everything that has to do with validation, bringing me no closer to a solution to my problem.Dardanelles
@Adrian: Have been studying the "Patterns, Principles and Practices of Domain-Driven Design - 1st Edition (2015)" book by Scott Millett for the past few months. But you are right, making this fit idiomatic Go is not an easy task I must admit. I can't remember where I read it, but it was mentioned that Go wasn't a suitable language when models become complex. What is your take on that?Dardanelles
Here's a repository I normally look to when asking similar questions. He also has a series of articles and a talk on youtube discussing the project.Covey
To the comment "Go wasn't a suitable language when models become complex," I would generally disagree, if you are able to segment the model into discrete packages and keep strong separation between them. If the shared model is quite large/complex, the implementation can become unwieldy in Go (though often this can be fixed by design improvements to create better separation). Because of its simplicity-first approach, Go is well-suited to microservices but a codebase can become harder to manage with true monoliths.Add
@Add I am also still OOP-minded Go noob I am afraid. Could you be more specific on the static vs. dynamic statement above? Are you referring to the way that what could be a single type is split into multiple parts (i.e. entity + value objects), constructed in various places and which only come together at runtime?Buonarroti
B
7

First of all, as other commenters rightfully say, you have to look at the goals of DDD and decide whether the approach has merit. DDD adds some complexity to the architecture (most of that in the initial phase when structuring your project and base types) and the amount of boilerplate and ceremony you'll have to deal with after that.

In many cases simpler designs, e.g. a CRUD approach, work best. Where DDD shines is in applications that are in themself more complex in terms of functionality and/or where the amount of features is expected to significantly grow over time. Technical advantages can come in terms of modularity, extensibility and testability, but - most importantly imho - providing a process in which you can take the non-technical stakeholders along and translate their wishes to code without losing them along the way.

There's a great series of blog posts, the Wild Workouts Go DDD Example, that takes you along a refactoring process of a traditional Go CRUD-based REST API design to a full-blown DDD architecture, in several steps.

Robert Laszczak, author of the series defines DDD as follows:

Ensure that you solve valid problems in the optimal way. After that implement the solution in a way that your business will understand without any extra translation from technical language needed.

And he sees Golang + DDD as the excellent way to write business applications.

Key to understand here is to decide how far you want to go (no pun intended) with your design. The refactoring gradually introduces new architecture concepts, and at each of these steps you should decide if it is enough for your use case, weigh the pros and cons to go further. They start very KISS with a DDD Lite version, and then later go further with CQRS, Clean Architecture, Microservices and even Event Sourcing.

What I see in many projects is that they immediately go the Full Monty, creating overkill. Especially Microservices and Event Sourcing add a lot of (accidental) complexity.


I am not well-versed in Go yet (actually quite new to the language), but'll give a stab at your options and offer some considerations. Maybe more experienced Go developers can correct me, where I go off-the-mark :)

For my own project I am looking into a Clean Architecture (Ports & Adapters, Inversion of Control) + CQRS + DDD combination.

The Wild Workouts example provides ample inspiration, but will need some tweaks and additions here and there.

My goal is that in the codebase's folder structure developers should immediately recognize where features / use cases (epics, user stories, scenario's) reside, and have self-contained, fully consistent domains that directly reflect the Ubiquitous Language and are separately testable. Part of testing will be text-only BDD scripts that can be easily understood by customer and end-users.

There will be some boilerplate involved, but - given the above - I think the pros outweigh the cons (if your application warrants DDD).

Your Option #1 looks best to me, but with some additional observations (note: I will stick to your naming, which will make some of this seem overkill.. again it is the ideas that count).

  • Instead of Entity I would say that Realm represents an AggregateRoot.
  • This can be implicitly so, or it can inline a base.AggregateRoot.
  • The aggregate root is the access point to the domain, and ensures its state is always consistent.
  • Hence the internal state of Realm should be immutable. State changes occur via functions.
  • Unless really trivial and not likely to change I'd implement FriendlyName value object in a separate file.
  • Also part of the domain is a RealmRepository but this provides no more than an interface.

Now I'm using CQRS, which is an extension to what's shown in your code snippets. In this:

  • There might be a ChangeFriendlyName Command handler in the Application layer.
  • The handler has access to a repository implementation e.g. InMemRealmRepository.
  • Might pass a CreateRealmParams to the Command, which then does its validation.
  • Handler logic might start by fetching a Realm aggregate from the database.
  • Then constructs a new FriendlyName (can also encapsulate in a Realm function call).
  • A function call to Realm updates state and queues a FriendlyNameChanged event.
  • The command handler persists the changes to the InMemory database.
  • Only if there were no errors the command handler invokes Commit() on the aggregate.
  • One or more queued events are now published e.g via an EventBus, handled where needed.

As for the code of Option #1 some changes (hope I'm doing this right) ..

realm.go - Aggregate root

type Realm struct {
   base.AggregateRoot

   friendlyName FriendlyName
}

// Change state via function calls. Not shown: event impl, error handling.
// Even with CQRS having Events is entirely optional. You might implement
// it solely to e.g. maintain an audit log.
func (r *Realm) ChangeFriendlyName(name FriendlyName) {
   r.friendlyName = name
   
   var ev = NewFriendlyNameChanged(r.id, name)

   // Queue the event.
   r.Apply(ev)
}

// You might use Params types and encapsulate value object creation,
// but I'll pass value objects directly created in a command handler.
func CreateRealm(id base.AID, name FriendlyName) (*Realm, error) {
   ar := base.NewAggregateRoot(id)

   // Might do some param validation here.

   return &Realm{
       AggregateRoot: ar,
       friendlyName: name,
   }, nil
}

friendlyname.go - Value object

type FriendlyName struct {
    value string
}

// Domain error. Part of ubiquitous language.
var FriendlyNameInvalid = errors.New("invalid friendly name")

func (n FriendlyName) String() string { return n.value }

func NewFriendlyName(input string) (FriendlyName, error) {
    if input == "" {
        return FriendlyNameInvalid
    }
    // perhaps some regexp rule here...

    return FriendlyName{value: input}, nil
}
Buonarroti answered 5/3, 2021 at 12:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.