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!
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).
Entity
I would say that Realm
represents an AggregateRoot
.base.AggregateRoot
.Realm
should be immutable. State changes occur via functions.FriendlyName
value object in a separate file.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:
ChangeFriendlyName
Command handler in the Application layer.InMemRealmRepository
.CreateRealmParams
to the Command, which then does its validation.Realm
aggregate from the database.FriendlyName
(can also encapsulate in a Realm
function call).Realm
updates state and queues a FriendlyNameChanged
event.Commit()
on the aggregate.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
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With