Caution: ValueObject is designed for demonstration purposes. Instead of directly using the design pattern as it appears in the package, you’d have to adjust the source code to the problem you are trying to solve.

A Value Object models a domain concept using natural lingo of the domain experts, such as “Passenger”, “Address”, and “Money”.

Any Value Object is created by 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.

How It Works

In R, a good option for creating a Value Object is to follow two instructions:

  • A Value Object is created by a function, rather than a class method; and
  • A Value Object returns a tibble, rather than a list or a vector.

In essence, a Value Object is a data type, like integer, logical, Date or data.frame data types to name a few. While the built-in data types in R fit any application, Value Objects are domain specific and as such, they fit only to a specific application. This is because, integer is an abstract that represent whole numbers. This abstract is useful in any application. However, a Value Object represent a high-level abstraction that appears in a particular domain.

An example of a Value Object is the notion of a “Person”. Any person in the world has a name. Needless to say, a person name is spelt by letters, rather than numbers. A Value Object captures these attribute as tibble columns and type checks:

Person <- function(given = NA_character_, family = NA_character_){
  stopifnot(is.character(given), is.character(family))
  stopifnot(length(given) == length(family))
  
  return(
    tibble::tibble(given = given, family = family)
    %>% tidyr::drop_na(given)
  )
}

Instantiating a person Value Object is done by calling the Person constructor function:

person <- Person(given = "Bilbo", family = "Baggins")

Getting to know the advantages of a Value Object, we should consider the typical alternative – constructing a Person by using the tibble function directly:

person <- tibble::tibble(given = "Bilbo", family = "Baggins")

Both implementations return objects with identical content and structure, that is, their column names, column types and cell values are identical. Then, why would one prefer using a Value Object and its constructor over the direct alternative?

There are four predominant qualities offered by the Value Object pattern which are not offered by the alternative:

  1. Readability. Each Value Object captures a concept belonging to the problem domain. Rather than trying to infer what a tibble is by looking at its low-level details, the Value Object constructor descries a context on a high-level.

  2. Explicitness. Since the constructor of the Value Object is a function, its expected input arguments and their type can be detailed in a helper file. Moreover, assigning input arguments with default values of specific type, such as NA (logical NA), NA_integer_, NA_character_, or NA_Date (see lubridate::NA_Date), expresses clearly the variable types of the Value Object.

  3. Coherence. The representation of a Value Object is concentrated in one place – its constructor. Any change, mainly modifications and extensions, applied to the constructor promise the change would propagate to all instances of the Value Objects. That means, no structure discrepancies between instances that are supposed to represent the same concept.

  4. Safety. The constructor may start with defensive programming to ensure the qualities of its input. One important assertion is type checking. Type checking eliminated the risk of implicit type coercing. Another important assertion is checking if the lengths of the input arguments meet some criteria, say all inputs are of the same length, or more restrictively, all inputs are scalars. Having a set of checks makes the code base more robust. This is because Value Objects are regularly created with the output of other functions calls, having a set of checks serves as pseudo-tests of these functions output throughout the code.

In addition to these qualities, there are two desirable behaviours which are not offered by directly calling tibble:

  1. Null Value Object. Calling the Value Object constructor with no input arguments returns the structure of the tibble (column names and column types).

  2. Default values for missing input arguments. In this manner, the Value Object has a well-defined behaviour for a person without a family name, such as Madonna and Bono.

In addition to native R data types, a Value Object constructor can receive other Value Objects as input arguments. Here are two examples that transmute Person to other Person-based concepts:

# A Passenger is a Person with a flight booking reference
Passenger <- function(person = Person(), booking_reference = NA_character_){
  stopifnot(all(colnames(person) %in% colnames(Person())))
  stopifnot(is.character(booking_reference))
  
  return(
    person 
    %>% tibble::add_column(booking_reference = booking_reference)
    %>% tidyr::drop_na(booking_reference)
  )
}

person <- Person(given = "Bilbo", family = "Baggins")
passenger <- Passenger(person = person, booking_reference = "B662HR")
print(passenger)
#> # A tibble: 1 × 3
#>   given family  booking_reference
#>   <chr> <chr>   <chr>            
#> 1 Bilbo Baggins B662HR
# A Diner is a Person that may have dinner reservation
Diner <- function(person = Person(), reservation_time = NA_POSIXct_){
  stopifnot(all(colnames(person) %in% colnames(Person())))
  stopifnot(is.POSIXct(reservation_time))
  
  return(
    person 
    %>% tibble::add_column(reservation_time = reservation_time)
  )
}

person <- Person(given = "Bilbo", family = "Baggins")
timestamp <- as.POSIXct("2021-01-23 18:00:00 NZDT")
diner <- Diner(person = person, reservation_time = timestamp)
print(diner)
#> # A tibble: 1 × 3
#>   given family  reservation_time   
#>   <chr> <chr>   <dttm>             
#> 1 Bilbo Baggins 2021-01-23 18:00:00

When to Use It

  • In situations where domain concepts are more important then the database schema. For example, when you are modelling Passengers, your first instinct might be to think about the different data sources you’d need for the analysis. You may envision “FlightDetails” and “CustomerDetails”. Next you will define the relationship between them. Instead, let the domain drive the design. Create a Passenger Value Object with the attributes you must have, regardless of any particular database schema.

  • In a function that runs within a specific context. Rather than having an input argument called data of type data.frame, use the appropriate Value Object name and pass it its constructor.

Audience <- Person

## Without a Value Object
clean_audience_data <- function(data) 
  dplyr::mutate(.data = data, given = stringr::str_to_title(given))

## With a Value Object
clean_audience_data <- function(attendees = Audience()) 
  dplyr::mutate(.data = attendees, given = stringr::str_to_title(given))

Note: Value Objects do not need to have unit-tests. This is because of two reasons: (1) Value Objects are often called by other functions that are being tested. That means, Value Objects are implicitly tested. (2) Value Objects are data types similarly to ‘data.frame’ or ‘list’. As such, they need no testing

Example: Person and Minister Value Objects

In this example, we are appointing elected officials to random ministries, just like in real life.

The nomination process comprises three components: input, function, output.

  • The input is the given and family names of a Person
  • The function receives a Person value object and pairs it with ministry titles
  • The output is a Minister value object

Person -> appoint_random_ministries

First, we implement the input type. Person() is the constructor of the Person value object.

#' @title Person Value Object Constructor
#' @description A Person encapsulates the information that constitute an individual 
#' @param given (`character`) Individual first name.
#' @param family (`character`) Individual last name.
#' @return (`Person`) Person value object.
Person <- function(given = NA_character_, family = NA_character_){
  stopifnot(is.character(given), is.character(family))
  stopifnot(length(given) == length(family))
  
  return(
    tibble::tibble(given = given, family = family)
    %>% tidyr::drop_na(given)
  )
}

Person()
#> # A tibble: 0 × 2
#> # … with 2 variables: given <chr>, family <chr>

In addition, we implement a predicate that validates whether an arbitrary data.frame is a Person value object.

# Create a test for objects of type Person
# * Extract the column names of Person by using its Null Object (returned by Person())
# * Check that the input argument has all the columns that a Person has
is.Person <- function(x) all(colnames(x) %in% colnames(Person()))

Second, we implement the output type. Minister() is the constructor of the Minister value object.

#' @title Minister Value Object Constructor
#' @decription A 'Minister' is a 'Person' with a ministry title. 
#' @param Person (`Person`) A member of parliament.
#' @param title (`character`) A string with one or more ministry titles.
#' @return (`Minister`) Minister value object.
Minister <- function(member = Person(), title = NA_character_){
  stopifnot(is.Person(member), is.character(title))
  stopifnot(nrow(member) == length(title) | all(is.na(title)))
  
  member %>% dplyr::mutate(title = title)
}

Third, we write a function that transforms Person into Minister.

#' @title Appoint Parliament Members to Ministries
#' @decription
#' Given one or more parliament members
#' When appoint_random_ministries is called
#' Then the parliament members are appointed to an office.
#' @param memeber (`Person`) A Person value object.
#' @return (`Minister`) Minister value object.
appoint_random_ministries <- function(member = Person()){
  positions <- c(
    "Arts, Culture and Heritage", "Finance", "Corrections",
    "Racing", "Sport and Recreation", "Housing", "Energy and Resources",
    "Education", "Public Service", "Disability Issues", "Environment",
    "Justice", "Immigration", "Defence", "Internal Affairs", "Transport"
  )
  
  Minister(member = member, title = sample(positions, size = nrow(member)))
}

Finally, we pair parliament members with ministries

# Listing New Zealand elected officials in 2020, we instantiate a Person Object,
# appoint them to random offices and return a Member value object.
set.seed(2020)

parliament_members <- Person(
  given = c("Jacinda", "Grant",     "Kelvin", "Megan", "Chris",   "Carmel"),
  family = c("Ardern", "Robertson", "Davis",  "Woods", "Hipkins", "Sepuloni")
)

parliament_members
#> # A tibble: 6 × 2
#>   given   family   
#>   <chr>   <chr>    
#> 1 Jacinda Ardern   
#> 2 Grant   Robertson
#> 3 Kelvin  Davis    
#> 4 Megan   Woods    
#> 5 Chris   Hipkins  
#> 6 Carmel  Sepuloni

appoint_random_ministries(member = parliament_members)
#> # A tibble: 6 × 3
#>   given   family    title                     
#>   <chr>   <chr>     <chr>                     
#> 1 Jacinda Ardern    Justice                   
#> 2 Grant   Robertson Transport                 
#> 3 Kelvin  Davis     Energy and Resources      
#> 4 Megan   Woods     Housing                   
#> 5 Chris   Hipkins   Education                 
#> 6 Carmel  Sepuloni  Arts, Culture and Heritage

Further Reading

Value Object on Wikipedia

Vernon, Vaughn. 2013. Implementing Domain-Driven Design. 1st Edition. Addison-Wesley Professional.