Model a domain concept using natural lingo of the domain experts, such as “Passenger”, “Address”, and “Money”.
ValueObject(given = NA_character_, family = NA_character_)
given | ( |
---|---|
family | ( |
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.
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:
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_
, or NA_Date
(see
lubridate::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
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))
In pipes and filters architecture.
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
Other base design patterns:
NullObject()
,
Singleton
# 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()#> # A tibble: 0 × 2 #> # … with 2 variables: given <chr>, family <chr># 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#> # 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 Sepuloniappoint_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