4 min read

I see your primitive and raise you a value object

I see your primitive and raise you a value object
Photo by Pranjall Kumar / Unsplash

After a bit of a break, due to vacation and needing a break, I wanted to start with a light and short subject. The term that gets thrown around is Primitive obsession. It is not a scary concept, even with these catchy buzz generators.

But what is wrong with these so-called "primitives" you speak of? Nothing. They're foundational data types in the language of your choice. And they fulfill their purpose as designed. I (or most people who throw these terms around) am giving them more meaning in your code base. Or as popularly called, domain. Like for example, an email address. Let me illustrate with a simple piece of code:

type User struct {
	Email    string
	Password string
}

func main() {
	user := User{
		Email:    "[email protected]",
		Password: "P@ssw0rd123",
	}

	if !isValidEmail(user.Email) {
		fmt.Println("Invalid email address")
	} else {
		fmt.Println("User is valid")
	}
}

func isValidEmail(email string) bool {
	re := regexp.MustCompile(`regex-for-email`)
    
	return re.MatchString(email)
}

For me at least, it is clear what is happening above. It is also pretty clear that a "simple" string has more "logic" associated with it. And it is not even "grouped" together with it, it can be in whatever or whatever place. And if validation changes, will you find all places where something is maybe done with email? Is it even named the same? And so on. This is where I start thinking it is time to "wrap" it all together.

Another sign of when you can apply this kind of thinking is when you see a (helper) code that does something similar. Or a service that exists separately from the "data" it operates on.

type EmailValidatorService struct {
	emailRegex *regexp.Regexp
}

func NewEmailValidatorService() *EmailValidatorService {
	emailRegex := regexp.MustCompile(`regex-for-email`)
	
	return &EmailValidatorService{emailRegex: emailRegex}
}

func (s *EmailValidatorService) Validate(email string) error {
	if !s.emailRegex.MatchString(email) {
		return fmt.Errorf("invalid email address")
	}
	
	return nil
}

For me, this is also a "valid" implementation. Wouldn't be my preferred choice. Why split these things if they're closely related? Then again, that is my take on it. I will stay away from what constitutes for a "valid" email address. That was one big rabbit hole, I would recommend jumping into it. Just for fun and to be humbled. By a concept, you think that you understand.

You can "extend" these base types of language, in some of them. Then again, if you're doing such things, I would like to encapsulate all this in a single place and not worry about where have I left it. And with this, we get to the point of this small write-up. The Value Object.

Value objects are used to encapsulate a simple set of data that is immutable and used to represent a value, such as an email address, money amount, or coordinates. Using a value object provides a meaningful and type-safe way to handle these values within your code. An example of this concept:

type Email struct {
	address string
}

func NewEmail(address string) (Email, error) {
	if !isValidEmail(address) {
		return Email{}, fmt.Errorf("invalid email address")
	}
	
	return Email{address: address}, nil
}

func (e Email) String() string {
	return e.address
}

func (e Email) Equals(other Email) bool {
	return e.address == other.address
}

func isValidEmail(email string) bool {
	re := regexp.MustCompile(`regex-for-email`)
	
	return re.MatchString(email)
}

As said, nothing scary. As you can see the actual value of the email is encapsulated within the struct Email. And now you have something that can encapsulate the validation or whatnot you have in store for that particular data structure. In other words, maybe you have heard something along the lines; Things that change together, stay together. Even the language of your choice has some. Possibly.

Value objects do not have an "identity" of their own. They are defined solely by their attributes. In other words, if all the values in one instance match all the values in another instance, the two instances are considered equal.

Another thing that comes to my mind, it is harder to make a mistake. In the first example, it would be possible to create an instance of User with an invalid email address. Nothing prevents you. While in our new and shiny value object, this becomes a bit harder. I saw many things where even the best-designed data structures just gave up.

func main() {
	email, err := NewEmail("[email protected]")
	if err != nil {
		fmt.Println(err)
		return
	}

	password, err := NewPassword("P@ssw0rd123")
	if err != nil {
		fmt.Println(err)
		return
	}

	user := User{
		Email:    email,
		Password: password,
	}

	fmt.Printf("Email: %s and Password: %s\n", user.Email, user.Password)
}

If you had an opportunity to hang around DDD "practitioners", these things shouldn't be a strange concept. The terms from Eric Evans blue book are thrown around and you probably caught this one at one point. To keep this constructive subject, I will stay away from DDD here. Let us say that you can use value objects to represent the domain concepts more clearly. Whatever gets you going, no judgment here. Just stick with whatever you choose as your poison of choice.

The example below showcases another good use of value objects, in my opinion.

type Currency string

const (
	USD Currency = "USD"
	EUR Currency = "EUR"
)

type Money struct {
	amount   uint64
	currency Currency
}

func NewMoney(cents uint64, currency Currency) (Money, error) {
	if !isValidCurrency(currency) {
		return Money{}, errors.New("invalid currency")
	}
	
	return Money{amount: cents, currency: currency}, nil
}

func (m Money) String() string {
	major, minor := m.amount/100, m.amount%100
	
	return fmt.Sprintf("%d.%02d %s", major, minor, m.currency)
}

func (m Money) Equals(other Money) bool {
	return m.amount == other.amount && m.currency == other.currency
}

func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, errors.New("currencies must match to add amounts")
	}
	
	return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

func isValidCurrency(currency Currency) bool {
	switch currency {
	case USD, EUR:
		return true
	default:
		return false
	}
}

The usage is showcased in the following snippet:

func main() {
	money1, err := NewMoney(10000, USD) // 100.00 USD
	if err != nil {
		fmt.Println(err)
		return
	}

	money2, err := NewMoney(2500, USD) // 25.00 USD
	if err != nil {
		fmt.Println(err)
		return
	}

	money3, err := NewMoney(20000, EUR) // 200.00 EUR
	if err != nil {
		fmt.Println(err)
		return
	}
	
	fmt.Println("Money1:", money1)
	fmt.Println("Money2:", money2)
	fmt.Println("Money3:", money3)

	fmt.Printf("Money1 equals Money2: %v\n", money1.Equals(money2)) // false
	fmt.Printf("Money1 equals Money3: %v\n", money1.Equals(money3)) // false

	money4, err := money1.Add(money2)
	if err != nil {
		fmt.Println(err)
		return
	}
	
	fmt.Println("Money4 (Money1 + Money2):", money4) // 125.00 USD
}

I don't know about you, new money, but this looks pretty readable and understandable. Then again, if these were "primitive" types, I wouldn't be screaming.

Naturally, this comes with tradeoffs. You're creating a "wrapper" around the language types. Which adds additional overhead to those "primitives". Is it justifiable, you can decide. Run some benchmarks and see if it is worth it.

Until next time, keep it primitive.