This website uses cookies to ensure you get the best possible experience. See our Cookies Policy.

PMG Digital Made for Humans

Applying Function Options to Domain Entities in Go

11 MINUTE READ | October 21, 2019

Applying Function Options to Domain Entities in Go

The notion of function options (or arguments, settings, configuration values) has been around for a while.

At a high level, a function option is a closure over some value that needs to be set on some other entity. By using some helper method, you can set a defined, yet flexible, set of values on an entity type.

The following shows a simple example of the use and application of function options.function_option_tldr.go

package entitytype Entity struct {	fieldOne string}type Option func(e *Entity)func FieldOne(fieldOne string) Option {	return func(e *Entity) {		e.fieldOne = fieldOne	}}func (e *Entity) WithOptions(options ...Option) {	for _, option := range options {		option(e)	}}//Usage:////e := &Entity{}//e.WithOptions(//    FieldOne("field one's value"),//)

Here, we see a few important things.

  1. We have a struct type, Entity, that we want to set values on.

  2. We define the Option type that allows us to set values on an Entity.

  3. We define the FieldOne function that closes over its argument and returns an Option to set an Entity‘s fieldOne field to the supplied value.

You can write fully correct and valuable programs without the use of function options. The main drive for our using them and for writing this post is mostly experimentation and evaluation. We wanted to try this out in our programs and compare and contrast with the “old” solutions. An “old” solution is to have a constructor function accept your parameters and set those values on the struct fields. This can get unwieldy if the number of arguments grows. You could also set values directly on a struct if the fields are exported, but then you have to deal with invalid or unwanted modifications to those struct fields from external code. See Additional Considerations for more exposition on the differences between solutions.

Now that we have the basics, let’s look at some more features we would want to add to make the solution easier to use and more robust.

For the remainder of this post, we are going to work with an 

User
 entity type with a subset of fields you would likely find in most applications. The entire working program is listed at the end of the post.

Obviously, if the struct fields are unexported, then there won’t be a way to retrieve their values outside of the package. In order to gain access, you will have to add getter methods to the entity type in question.

Normally, you will find a getter per available option type, unless the field isn’t intended for use outside of the package. These getters are very simple methods with a single return line, so I won’t detail them here.

A constructor function allows client code to create instances of the entity type. This will most likely be a requirement if the entity’s fields are unexported; otherwise, there would not be a way to set values on the entity. In the case where the entity has some fields that you would like to keep unexported (for the sake of disallowing undesired modifications), then you can add a “modifier method” on the entity type. The constructor functions can create a zero-valued entity, and then use the modifier method to get the entity in the correct state. In our example, NewXxx is the conventional name for constructor functions. At its simplest, a modifier method will accept an entity pointer, and loop through all parameter Options and call those Options with the entity receiver. The With and related with methods on the User type are our modifier methods.

In the real world, there will be situations where default values are desired, or some dependency is intrinsic to an entity. In this case, every single option may not be required when constructing an entity. When this occurs, you can provide an API for constructor functions that accept only the required fields, and optionally allow overriding the generated ones. In our User example, we have three (Id, DisplayName, and CreatedAt) options / fields. But, when we create a User, the Id can be generated and the entity is created at the current time. If you are okay with not injecting those values, then an extra NewDisplayName function can add some sugar to the package’s interface.

Depending on the context and use of an entity type with Options, you may or may not need to update certain fields after constructing the entity. With some extra work, we can ensure that certain fields don’t get updated after construction. First, we will need to know whether or not an Option is being called while the entity it is modifying is being constructed. One simple solution to this problem is to add a isConstructing bool field to the entity type. The constructor functions will set it to true upon invocation, and set it to false before returning — this is what we do in our example. Note: there are other ways to enforce this “read-only” field type, like wrapped Option functions, or altering the constructor method signatures and limiting the available Options. Next, let’s consider adding an error type CannotSetOptionError struct with a single optionName field. Assume it implements the error interface. If an Option is called for a field that shouldn’t be set after construction, then the Option will check the entity’s isConstructing field. If false, then the Option can return a CannotSetOptionError to indicate an invalid modification attempt. This additionally requires adding an error return parameter to the Option type.

Validation is one of the best and most apparent benefits from using unexported struct fields with a single modifier method to update an entity. The single method that accepts a list of Options acts like a transaction of updates on the entity, where all updates can be validated before the updated entity returns to the rest of the application. In this way, we can ensure that there are no instances with invalid fields anywhere in the program. In our example, we have the single modifier method with that calls validate after applying the Options but before returning. If our validate method returns a non-nil error, we can expect that error to indicate some sort of invalid state in the entity. And, by having with being the only method that is allowed to call Options with our entity, we need to call validate in exactly one spot. We know that once an entity has been returned from an exported function in our package, our package has ensured the entity is in a valid state. The validate method can be whatever you want it to be, acting under the assumption a non-nil error means the entity is invalid and the program shouldn’t use it. You can use a validation library for struct validation, or you can roll your own validation – it’s all flexible; cross-field validation is easy since you get the full language to work within your method.

When all of the entity construction and validation are wrapped into a small API, it becomes simple to test validation and field setting. With the constructor functions accepting a slice of Options to use, we can default to supplying Options that we know are all valid, and then extend those Options to change the state of the entity and testing our validation and getter methods against the extra Options. All of the tests are also provided at the end of this post.

There are a few more thoughts I want to leave you with when considering this solution for handling domain entity types.

  1. When all of the fields are unexported, we limit our package to be the only code that can modify those fields. This prevents client code from modifying our entities in ways we don’t allow.

  2. You can still have unexported fields on the struct type, but you lose the requirement for its correlated Option, and you allow client code to change the field’s value. If this is okay and works for your use case, then go for it.

  3. There is additional boilerplate code to handle updates. So, instead of having a one line user.DisplayName = "New Display Name"solution, you now need a separate method and type as well as more logic to allow for the update. We add even more boilerplate with the getter methods and the validation. However, code generation is a great way to get around manually writing this boilerplate, and avoid errors when manually writing it.

  4. The Option type, being a function, is going to have a higher performance cost than a simple assignment statement. In general, this shouldn’t be an issue, but it could become one if you are bulk creating or modifying these entities (like scanning from a database).

  5. The API is generally cleaner and easier to work with than having public struct fields or a single constructor function with a strict list of parameters. This value add isn’t as high with a few number of fields, but grows with the number of fields.

  6. From the point above, if you are regularly interacting with these entities, an easier-to-use API leads to a better developer experience, but it’s all a trade-off.

user_options.go

package userimport (	"bytes"	"errors"	"fmt"	"time"	"github.com/google/uuid")var (	ErrUnsetId = errors.New("user: unset Id")	ErrInvalidDisplayName = errors.New("user: invalid DisplayName")	ErrUnsetCreatedAt = errors.New("user: unset CreatedAt"))type CannotSetOptionError struct {	OptionName string}func (e *CannotSetOptionError) Error() string {	return fmt.Sprintf("user: cannot set option %s", e.OptionName)}type User struct {	isConstructing bool	id uuid.UUID	displayName string	createdAt time.Time}func NewDisplayName(displayName string) (*User, error) {	id, err := uuid.NewRandom()	if err != nil {		return nil, err	}	return New(		Id(id),		DisplayName(displayName),		CreatedAt(time.Now()),	)}func New(options ...Option) (*User, error) {	u := &User{		isConstructing: true,	}	if err := u.with(options...); err != nil {		return nil, err	}	u.isConstructing = false	return u, nil}func (u *User) With(options ...Option) (*User, error) {	result := &User{}	*result = *u	if err := result.with(options...); err != nil {		return nil, err	}	return result, nil}func (u *User) with(options ...Option) error {	for _, option := range options {		if err := option(u); err != nil {			return err		}	}	if err := u.validate(); err != nil {		return err	}	return nil}func (u *User) validate() error {	if isIdEmpty(u.id) {		return ErrUnsetId	}	if len(u.displayName) == 0 {		return ErrInvalidDisplayName	}	if u.createdAt.IsZero() {		return ErrUnsetCreatedAt	}	return nil}func (u *User) Id() uuid.UUID {	return u.id}func (u *User) DisplayName() string {	return u.displayName}func (u *User) CreatedAt() time.Time {	return u.createdAt}type Option func(u *User) errorfunc Id(id uuid.UUID) Option {	return func(u *User) error {		if !u.isConstructing {			return &CannotSetOptionError{OptionName: "Id"}		}		u.id = id		return nil	}}func DisplayName(displayName string) Option {	return func(u *User) error {		u.displayName = displayName		return nil	}}func CreatedAt(createdAt time.Time) Option {	return func(u *User) error {		if !u.isConstructing {			return &CannotSetOptionError{OptionName: "CreatedAt"}		}		u.createdAt = createdAt		return nil	}}

user_options_test.go

package userimport (	"testing"	"time"	"github.com/google/uuid")var (	validId, _       = uuid.NewRandom()	validDisplayName = "Valid Display Name"	validCreatedAt   = time.Now())var (	validOptions = []Option{		Id(validId),		DisplayName(validDisplayName),		CreatedAt(validCreatedAt),	})func TestNewDisplayName_ReturnsUserWithSetDisplayName(t *testing.T) {	user, err := NewDisplayName("dn")	if err != nil {		t.Fatal(err)	}	if user.DisplayName() != "dn" {		t.Fatal()	}}func TestNew_ReturnsUserAndNilErrorWithValidOptions(t *testing.T) {	user, err := New(validOptions...)	if err != nil {		t.Fatal(err)	}	if user == nil {		t.Fatal(user)	}}func TestNew_ReturnsInvalidErrorForAppropriateOptions(t *testing.T) {	cases := []struct {		invalidOption Option		err           error	}{		{Id([16]byte{}), ErrUnsetId},		{DisplayName(""), ErrInvalidDisplayName},		{CreatedAt(time.Time{}), ErrUnsetCreatedAt},	}	for i, tc := range cases {		_, err := New(append(validOptions, tc.invalidOption)...)		if err != tc.err {			t.Errorf("%d: err = %v WANT %v", i, err, tc.err)		}	}}func TestUser_With_ErrorsWithAppropriateCannotSetErrors(t *testing.T) {	user, _ := New(validOptions...)	cases := []struct {		cannotSetOption Option		optionName      string	}{		{Id(validId), "Id"},		{CreatedAt(time.Now()), "CreatedAt"},	}	for i, tc := range cases {		result, err := user.With(tc.cannotSetOption)		if csError, _ := err.(*CannotSetOptionError); csError.OptionName != tc.optionName {			t.Errorf("%d: wrong error %v", i, err)		}		if result != nil {			t.Errorf("%d: result not nil", i)		}	}}func TestUser_With_AllowsSettingAppropriateOptions(t *testing.T) {	user, _ := New(validOptions...)	cases := []struct {		setOption Option	}{		{DisplayName("name")},	}	for i, tc := range cases {		result, err := user.With(tc.setOption)		if err != nil {			t.Errorf("%d: err = %v", i, err)		}		if result == user {			t.Fatalf("%d: result equals user", i)		}	}}func TestUser_Id_ReturnsTheSetId(t *testing.T) {	id, _ := uuid.NewRandom()	user, _ := New(append(validOptions, Id(id))...)	if !areIdsEqual(user.Id(), id) {		t.Fatal()	}}func TestUser_DisplayName_ReturnsTheSetDisplayName(t *testing.T) {	user, _ := New(append(validOptions, DisplayName(t.Name()))...)	if user.DisplayName() != t.Name() {		t.Fatal()	}}func TestUser_CreatedAt_ReturnsTheSetCreatedAt(t *testing.T) {	now := time.Now()	user, _ := New(append(validOptions, CreatedAt(now))...)	if !user.CreatedAt().Equal(now) {		t.Fatal()	}}func areIdsEqual(a, b uuid.UUID) bool {	return bytes.Equal([]byte(a[:]), []byte(b[:]))}func isIdEmpty(id uuid.UUID) bool {	empty := [16]byte{}	return bytes.Equal([]byte(id[:]), empty[:])}

Insights meet inbox

Sign up for weekly articles & resources.

There are two great blog posts here and here that cover the general subject of function options in more detail.


Posted by Eric Elsken

Related Content

Get Inspired

Working with an Automation Mindset

5 MINUTES READ | August 22, 2019

Get Inspired

3 Tips for Showing Value in the Tech You Build

5 MINUTES READ | April 24, 2019

thumbnail image

Get Informed

Testing React

13 MINUTES READ | March 12, 2019

Get Informed

A Beginner’s Experience with Terraform

4 MINUTES READ | December 20, 2018

Get Informed

Tips for Holiday Reporting Preparedness

3 MINUTES READ | November 5, 2018

thumbnail image

Get Insights

Navigating the Amazon Ecosystem

2 MINUTES READ | September 10, 2018

Get Informed

SEO For ReactJS Websites

4 MINUTES READ | April 11, 2018

Get Insights

Our Approach to Marketing Automation

7 MINUTES READ | November 16, 2017

thumbnail image

Get Inspired

Five Years and Three Data Lessons Learned at PMG

4 MINUTES READ | December 18, 2015

Get Informed

The New Google My Business API

1 MINUTE READ | December 17, 2015

Get Informed

2016 Data Strategy Checklist For Digital Marketers

6 MINUTES READ | December 1, 2015

ALL POSTS