Value Object
add_value_object(name, domain)
name | ( |
---|---|
domain | ( |
A Value Object is a function that receives input, applies some
transformations, and outputs the results in some data structure such as
a vector
, a list
or a data.frame
.
Tip: In R, a good practice is to set the Value Object to return a
data.frame
at its output. This is because often the Value
Object is
called during data wrangling operations.
Functions are things [@Wlaschin2018, p. 149]
Intuitive examples of Value Objects are:
Money: Money is a useful Value Object that captures
monetary value. It is a combination of Amount (numeric
) and
Currency (character
). For instance 20 USD and 20 NZD, have
different monetary value. However, we can create designated
functions to add/subtract one Money Value Object from another.
Person: Person is a Value Object that is representing an
Object in our earthly life. It is a combination of First Name
(character
), optionally Last Name (character
), Age (numeric
)
or Birthdate (Date
), etc.
In the context of DDD, a Value Object models a domain concept. A Value Object is an object that is identified only by its data and doesn’t have a long-lived identity.
For example, think about the diners, i.e. customers, of a Pizzeria. Associating a food order and a customer requires ephemeral customer identity. The customer name matters. It is used to identify for whom a particular Pizza was made for. The names Hilary and Bill are not equal. However, once the customer has left the Pizzeria, the customer’s existence dissipates. On the other hand, if the customer is enrolled in a restaurant loyalty program, then its unique identifier on the system is both essential and long-lived.
add_value_object
do?Given the Value Object name
, and its domain
name,
When add_value_object
is called
add_value_object(name = 'Customer', domain = 'Pizza Ordering')
Then the function:
Creates a Value Object boilerplate with several input arguments;
Places the file at R/pizza_ordering-value-Customer.R; and
Opens the file (only in interactive mode).
Tip: You don’t need to remember the naming style for the different DDD components.
Instead, ddd
takes care of naming style for all domain
objects.
This way DDD file names, classes and functions are congruent with each other.
The boilerplate code produced by add_value_object
looks likes this:
R/pizza_ordering-value-Customer.R
# ------------------------------------------------------------------------- #' @title Customer Value Object #' @return (`data.frame`) #' @export #' @family Pizza Ordering #' @keywords internal #' @noRd Customer <- function( size = NA_character_, slices = 4L, toppings = list(), takeaway = NA ){ tibble::tibble( size = size, slices = slices, toppings = toppings, takeaway = takeaway ) } Customer <- decorators::validate_arguments(Customer)
The boilerplate code includes three key activities covered by any Value Object:
Assigning default values to input argument values, such as different types of NA.
Validating the type and value range (if applicable) of the input arguments; and
Organizing the input arguments in a data structure, such as a
data.frame
.
In addition, the Value Object include transformations on the input
arguments before enframing them in a data structure. Common
transformations include: base::serialize
, jsonlite::toJSON
,
tidyr::unite
, and snakecase::to_title_case
to name a few.
The need that Value Object serve is not new. Until now you have probably been using a degenerated version of a Value Object in different places of the software.
The simplest way to implement a Value Object in R is with a function
that returns a data.frame
row. Compare the following two patterns for
creating a data.frame
that holds a Customer data.
First, using the tibble::tibble
function directly for constructing the
Customer
customer <- tibble(given = "Bilbo", family = "Baggins")
Second, using a Value Object as a surrogate for constructing the Customer
Customer <- function(given = NA_character_, family = NA_character_) { stopifnot(is.character(given)) stopifnot(is.character(family)) tibble(given = given, family = family) } customer <- Customer(given = "Bilbo", family = "Baggins")
Both patterns return identical objects, equal in content and structure i.e. column names and classes.
Why would one prefer using a Value Object over the direct alternative? There are three apparent qualities offered by the Value Object pattern which are not offered by the alternative:
Safety. Each input argument belongs to a certain type. This is guaranteed by type checking at the beginning of the function.
NULL Value Object. Calling the Value Object with no input
arguments returns the structure of the tibble
with default
content, such as NULL
, NA
, or actual values.
Default values for missing input arguments. In this manner, the Value Object has a well-defined behaviour for a customer without a last name, such as Madonna and Bono.
There are more subtle qualities offered by a Value Object over direct implementation. First, it allows you to capture new knowledge about the domain. Say, you have discovered that the Pizzeria makes deliveries. In this case, the Customer details need to include an address and a phone number. With a Value Object, you can rewrite the Customer concept as:
Customer <- function(given = NA_character_, family = NA_character_, phone = NA_integer_, address = NA_character_) { stopifnot(is.character(given)) stopifnot(is.character(family)) stopifnot(is.integer(phone)) stopifnot(is.character(address)) tibble(given = given, family = family, phone = phone, address = address) }
Notice that former calls to Customer
in the code base, which modelled
the ordering pizza process for diners, are not impacted by the added
input arguments. For example, the call of previous diner works well.
customer <- Customer(given = "Bilbo", family = "Baggins")
The Value Object ascribes NA values for the phone number and address of diners, that is customers in the restaurant.
The second subtle quality regards tests. If you write unit-test test cases against returning objects, then without a Value Object, your test expectations capture only the available knowledge at the moment of writing the test. For example, a test that validates the structure of a returning data from a function call,
test_that('calling Order$get_customer_info() returns the desired results', { expect_setequal( colnames(Order$get_customer_info()), c("given", "family", "phone", "address") ) })
can instead be written against the Customer
Value Object,
test_that('calling Order$get_customer_info() returns the desired results', { expect_setequal( colnames(Order$get_customer_info()), colnames(Customer()) ) })
By avoiding hard coding of column names, your tests evolve along side the code base.
Querying an Entity, that is by using its public query methods, should return a Value Object.
Calling Value Object can happen inside another Value Object.
Reusing Value Object in tests, such as unit-tests and integration-tests, is useful when the test aims to capture the structure of a returning object.
[@Wlaschin2018, p. 149]: R:@Wlaschin2018,%20p.%20149
Other domain object generators:
add_domain_service()
,
add_entity()