OOP Design Patterns in R: Factory, Strategy & Observer in R6
Design patterns are reusable solutions to common programming problems. While R isn't traditionally thought of as an OOP language, the R6 class system makes patterns like Factory, Strategy, Observer, Singleton, and Builder clean and practical to implement.
These patterns are especially useful when building R packages, Shiny apps, or any system with complex, interacting components.
Factory Pattern
The Factory pattern creates objects without exposing the creation logic. A factory function or class decides which class to instantiate based on input.
library(R6)
# Define a family of related classes
CSVReader <- R6Class("CSVReader",
public = list(
read = function(path) {
cat("Reading CSV:", path, "\n")
# read.csv(path) in real code
data.frame(x = 1:3, y = 4:6)
}
)
)
JSONReader <- R6Class("JSONReader",
public = list(
read = function(path) {
cat("Reading JSON:", path, "\n")
# jsonlite::fromJSON(path) in real code
list(x = 1:3, y = 4:6)
}
)
)
ExcelReader <- R6Class("ExcelReader",
public = list(
read = function(path) {
cat("Reading Excel:", path, "\n")
# readxl::read_excel(path) in real code
data.frame(x = 1:3, y = 4:6)
}
)
)
# The Factory function
create_reader <- function(file_path) {
ext <- tolower(tools::file_ext(file_path))
switch(ext,
"csv" = CSVReader$new(),
"json" = JSONReader$new(),
"xlsx" = ExcelReader$new(),
"xls" = ExcelReader$new(),
stop("Unsupported file type: ", ext)
)
}
# Usage -- caller doesn't need to know which class is created
reader <- create_reader("data.csv")
result <- reader$read("data.csv")
reader2 <- create_reader("config.json")
result2 <- reader2$read("config.json")
The Factory pattern is useful when:
You have multiple classes that share an interface
The caller shouldn't need to know which concrete class to use
You might add new formats later without changing calling code
Strategy Pattern
The Strategy pattern lets you swap algorithms at runtime. Define a family of interchangeable algorithm objects and inject one into a context object.
library(R6)
# Strategy: different imputation methods
MeanImputer <- R6Class("MeanImputer",
public = list(
impute = function(x) {
x[is.na(x)] <- mean(x, na.rm = TRUE)
x
},
name = function() "mean"
)
)
MedianImputer <- R6Class("MedianImputer",
public = list(
impute = function(x) {
x[is.na(x)] <- median(x, na.rm = TRUE)
x
},
name = function() "median"
)
)
ZeroImputer <- R6Class("ZeroImputer",
public = list(
impute = function(x) {
x[is.na(x)] <- 0
x
},
name = function() "zero"
)
)
# Context: uses whichever strategy is injected
DataCleaner <- R6Class("DataCleaner",
public = list(
initialize = function(strategy) {
private$strategy <- strategy
},
set_strategy = function(strategy) {
private$strategy <- strategy
invisible(self)
},
clean = function(df) {
cat("Imputing with strategy:", private$strategy$name(), "\n")
for (col in names(df)) {
if (is.numeric(df[[col]]) && any(is.na(df[[col]]))) {
df[[col]] <- private$strategy$impute(df[[col]])
}
}
df
}
),
private = list(
strategy = NULL
)
)
# Create data with NAs
df <- data.frame(
a = c(1, NA, 3, NA, 5),
b = c(10, 20, NA, 40, 50)
)
cat("Original:\n")
print(df)
# Try different strategies
cleaner <- DataCleaner$new(MeanImputer$new())
cat("\nMean imputed:\n")
print(cleaner$clean(df))
cleaner$set_strategy(MedianImputer$new())
cat("\nMedian imputed:\n")
print(cleaner$clean(df))
cleaner$set_strategy(ZeroImputer$new())
cat("\nZero imputed:\n")
print(cleaner$clean(df))
Observer Pattern
The Observer pattern lets objects subscribe to events and get notified when something happens. This is the backbone of reactive programming (and Shiny).
library(R6)
# Event emitter (subject)
EventEmitter <- R6Class("EventEmitter",
public = list(
on = function(event, callback) {
if (is.null(private$listeners[[event]])) {
private$listeners[[event]] <- list()
}
private$listeners[[event]] <- c(private$listeners[[event]], list(callback))
invisible(self)
},
emit = function(event, ...) {
callbacks <- private$listeners[[event]]
if (!is.null(callbacks)) {
for (cb in callbacks) {
cb(...)
}
}
invisible(self)
},
remove_all = function(event) {
private$listeners[[event]] <- NULL
invisible(self)
}
),
private = list(
listeners = list()
)
)
# A model that emits events when data changes
DataModel <- R6Class("DataModel",
inherit = EventEmitter,
public = list(
initialize = function(data = data.frame()) {
super$initialize()
private$data <- data
},
add_row = function(...) {
new_row <- data.frame(..., stringsAsFactors = FALSE)
private$data <- rbind(private$data, new_row)
self$emit("data_changed", private$data)
invisible(self)
},
get_data = function() private$data
),
private = list(
data = NULL
)
)
# Create model and attach observers
model <- DataModel$new()
# Observer 1: log changes
model$on("data_changed", function(data) {
cat("[Logger] Data now has", nrow(data), "rows\n")
})
# Observer 2: validate data
model$on("data_changed", function(data) {
if (nrow(data) > 3) cat("[Validator] Warning: dataset getting large!\n")
})
# Observer 3: summary stats
model$on("data_changed", function(data) {
if (ncol(data) > 0 && is.numeric(data[[1]])) {
cat("[Stats] Mean of first column:", mean(data[[1]]), "\n")
}
})
# Add data -- all observers are notified
model$add_row(x = 10, y = "a")
cat("\n")
model$add_row(x = 20, y = "b")
cat("\n")
model$add_row(x = 30, y = "c")
cat("\n")
model$add_row(x = 40, y = "d")
Singleton Pattern
The Singleton pattern ensures only one instance of a class exists. Useful for configuration, logging, or database connection pools.
library(R6)
# Singleton using a closure
AppConfig <- (function() {
instance <- NULL
R6Class("AppConfig",
public = list(
initialize = function() {
if (!is.null(instance)) {
stop("Use AppConfig$get_instance() instead of $new()")
}
private$settings <- list()
},
set = function(key, value) {
private$settings[[key]] <- value
invisible(self)
},
get = function(key, default = NULL) {
val <- private$settings[[key]]
if (is.null(val)) default else val
},
print = function(...) {
cat("AppConfig:", length(private$settings), "settings\n")
for (nm in names(private$settings)) {
cat(" ", nm, "=", private$settings[[nm]], "\n")
}
}
),
private = list(
settings = NULL
)
)
})()
# Simple singleton via manual management
config <- AppConfig$new()
config$set("debug", TRUE)
config$set("max_retries", 3)
config$print()
# Access from anywhere
cat("Debug:", config$get("debug"), "\n")
cat("Timeout:", config$get("timeout", default = 30), "\n")
Builder Pattern
The Builder pattern constructs complex objects step by step, especially when an object has many optional parameters.
Exercise 1: Implement a Factory that creates different normalization strategies: MinMaxScaler (0-1), StandardScaler (z-score), and RobustScaler (using median/IQR).
Q: Are design patterns overkill for R? For scripts and one-off analyses, yes. For packages, Shiny apps, and production systems, no. Patterns make code more maintainable and testable.
Q: Can I use design patterns with S3 or S4? Yes, but R6's mutable objects and encapsulation make patterns much more natural. Factory can work with S3 (just return different S3 objects). Strategy and Observer are awkward without mutable state.
Q: Which pattern is most useful for R programmers? Strategy is probably the most practical for data science workflows (swappable models, preprocessors, or scoring functions). Observer is essential for Shiny development.