In this article, we'll see what are Value Object and how they can help you bring meaningful concepts into your code.
First, let's define what is a Value Object.
- It defines a conceptual whole, e.g. its attributes form a meaningful concept in your domain;
- It is immutable, e.g. once instantiated it can never change;
- It is equal to another Value Object if their values are equals;
- It is short lived, can be discarded and replaced at will (as a side effect of the two point above);
Let's take a concrete example that is probably familliar to anyone who have dealt with money (product prices, checkout, payment...) in a codebase.
Dealing with money
It is not rare to stumble upon code that deal with money this way:
function doSomething(
other: { foo: string; amount: number; currency: string },
amount: number,
currency: string
) {
const amount_in_cents = Math.floor(amount * 100) // are we sure amount was in decimal? ๐คทโโ๏ธ
if (other.currency !== currency) {
throw new Error("Cannot sum two amount of different currency")
}
other.amount += amount_in_cents
// ...
}
So what's the problem with this code?
- First, is the
amount
in cents or in decimal? We don't know and the type does not help much here. - Second, we don't know what
currency
represents. It's a string but is it a currency code? - As a side effect, we mutate the
other.amount
with potential wrong values
We cannot tell from this code what amount and currency are. We need to do some defensive programming and check if the currency is in the expected format, but we cannot be sure about the format of amount just by checking the code. Is it in cents or in decimal?
Also, what is an amount by itself? And a currency? What do they represent? Is 25 USD the same as 25 and USD separately?
Can I change the currency to "EUR", or would it break the code?
In other word, are currency and amount linked together, thus providing a meaningful semantic thing in our domain? They probably are and we should model it as such.
Introducing the Money concept
In the code above, we deal with Money, e.g. an amount in a specific currency. So let's modelize this concept:
class Money {
readonly value: number
readonly currency: string
constructor(value: number, currency: string) {
this.value = value
this.currency = currency
}
}
Now, we start having something meaningful in our domain. The previous function signature becomes:
function doSomething(other: any, amount: Money) {}
We actually grouped a bunch of primitives under a single umbrella, providing some explicit semantics of our context.
It is not perfect yet
We can still instantiate it with a decimal amount, or any currency string...
Since a Value Object cannot change over time (immutability), we need to enforce its validity at construction. And that's another great thing about Value Object, they are responsible for their validation. So we cannot have invalid values used to instantiate a Value Object.
const VALID_CURRENCY_CODE = ["USD", "EUR"]
class Money {
readonly value: number // cannot be mutated
readonly currency: string // cannot be mutated
constructor(value: number, currency: string) {
this._assertValueInCents(value)
this._assertValidCurrency(currency)
// other validations if needed
this.value = value
this.currency = currency
}
private _assertValueInCents(value: number) {
if (!Number.isInteger(value)) {
throw new MoneyInvalidAmountException(
"The value must be represent the value in cents"
)
}
}
private _assertValidCurrency(currency: string) {
if (!VALID_CURRENCY_CODE.includes(currency)) {
throw new MoneyInvalidCurrencyException(
`The currency must be one of ${VALID_CURRENCY_CODE}`
)
}
}
}
And now, trying to create an invalid Money
in our domain would be impossible: new Money(10.50, "EUR");
will throw because the amount is not an integer (e.g. the amount in cents).
This Money Value Object does not provide any real behaviour though. And that's alright in general, but in this case we could make it a bit more useful.
So if you want to sum two amounts together, you still need to check their currencies.
But we can do better, and encapsulate the logic of summing a Money object with another one directly inside this class.
class Money {
readonly value: number
readonly currency: string
constructor(value: number, currency: string) {
// same as before
}
sum(another: Money): Money {
if (this.currency !== another.currency) {
throw new MoneyIllegalOperation(
"Cannot sum two money of different currency"
)
}
return new Money(this.value + another.value, this.currency)
}
}
Note that the sum(another: Money): Money
returns a new Money Value Object. It does not mutate it! Remember that a Value Object is immutable and can be discarded and replaced by a new one at any time.
The code becomes way simpler, and safer to use since we don't need to repeat the validation everytime we sum two amounts. A now, when we deal with amount: Money
, we are confident 100% confident the currency is valid, and the value is in cents.
function doSomething(other: { foo: string; amount: Money }, amount: Money) {
// ...
other.amount = other.amount.sum(amount) // will throw if not possible, or return a new Money with the summed amounts.
// ...
}
Note: The other
object is an anemic domain model, and this is not the focus for this article :)
Finally
We introduced the concept of Money into our domain. We gave it the responsibility to validate itself, and we made it a bit more than a simple bag of parameters.
The result is a easier code to read, understand, maintain in the long run when your business requirements change, and to test!
So start extracting some concepts, that is repeated in a lot of place, into Value Object. Then move their validation rules inside.
Top comments (0)