ValueObject {R6P} | R Documentation |
Value Object Pattern
Description
Model a domain concept using natural lingo of the domain experts, such as "Passenger", "Address", and "Money".
Usage
ValueObject(given = NA_character_, family = NA_character_)
Arguments
given |
( |
family |
( |
Details
<div class="alert alert-danger"> **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. </div> <!– One line about what the function does –>
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; andA 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:
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.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_
, orNA_Date
(seelubridate::NA_Date
), expresses clearly the variable types of the Value Object.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.
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
:
Null Value Object. Calling the Value Object constructor with no input arguments returns the structure of the
tibble
(column names and column types).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 typedata.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))
In pipes and filters architecture.
<div class="alert alert-warning"> **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 </div>
See Also
Other base design patterns:
NullObject()
,
Singleton
Examples
# See more examples at <https://tidylab.github.io/R6P/articles>
# In this example we are appointing elected officials to random ministries, just
# like in real-life.
Person <- ValueObject
Person()
# 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()))
# A 'Minister' is a 'Person' with a ministry title. We capture that information
# in a new Value Object named 'Minister'.
# The Minister constructor requires two inputs:
# 1. (`Person`) Members of parliament
# 2. (`character`) Ministry titles
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)
}
# Given one or more parliament members
# When appoint_random_ministries is called
# Then the parliament members are appointed to an office.
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)))
}
# 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
appoint_random_ministries(member = parliament_members)