Code Structure

Last updated on 2026-06-26 | Edit this page

Estimated time: 90 minutes

Overview

Questions

  • How can we best structure code?
  • What is a common code structure (pattern) for creating software that can read input from command line?
  • What are conventional places to store data, code, results, tests, auxiliary information and metadata within our software or research project?

Objectives

After completing this episode, participants should be able to:

  • Structure code into smaller, reusable components with a single responsibility/functionality.
  • Use the common code pattern for creating software that can read input from command line
  • Follow best practices for organising software/research project directories for improved readability, accessibility and reproducibility.

In the previous episode we have seen some tools and practices that can help up improve readability of our code - including breaking our code into asmall, reusable functions that perform one specific task. We are going to explore a bit more how using common code structures can improve readability, accessibility and reusability of our code, and will expand these practices on our (research or code) projects as a whole.

At this point, the code in your local software project’s directory should be as in: https://github.com/carpentries-incubator/better-research-software-r/blob/main/tree/04-code-structure

In the previous section we discussed using the package renv.lock to track packages and their dependencies. At this time, you should have a current renv.lock files and you should have restored the packages library.

You can check that the project is up-to-date with

R

renv::status() #you can run this any time

If you don’t have up-to-date packages, you can restore the packages and their dependencies by running

R

renv::restore("renv.lock")

Functions for Modular and Reusable Code


As we have already seen in the previous episode - functions play a key role in creating modular and reusable code. After extracting units of functionality into separate functions, the main part of our code became much simpler and more readable, only containing the invocation of the following three functions:

R


eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

When writing functions, you should be following the following principles:

  • Each function should have a single, clear responsibility. This makes functions easier to understand, test, and reuse.
  • Write functions that can be easily combined or reused with other functions to build more complex functionality.
  • Functions should accept parameters to allow flexibility and reusability in different contexts; avoid hard-coding values inside functions/code (e.g. data files to read from/write to) and pass them as arguments instead.

We can further refactor our code to extract more code into separate functions:

  • text_to_duration function to convert the spacewalk duration text into a number to allow for arithmetic calculations elsewhere in the code
  • add_duration_hours function to add this numerical data (generated by the text_to_duration function) as a new column in our dataset.
  • replace the line duration_hours = {parts <- stringr::str_split(duration, ":", n = 2, simplify = TRUE) as.numeric(parts[, 1]) + as.numeric(parts[, 2]) / 60} in plot_cumulative_time_in_space function with the invocation of add_duration_hours function

Remember to add roxygen2 comments to the new functions to explain their functionality.

You will need to share the code below with the learners via copy-and-paste either in shared notes or chat in a virtual training. Then you can highlight and describe the changes.

Our new code (with two new functions text_to_duration and add_duration_hours) may look like the following:

R


# EVA cumulative time pipeline (tidyverse-first, with reusable functions + roxygen-style docs)
library(tidyverse)
library(jsonlite)
library(lubridate)

# Files
input_file  <- "./eva-data.json"
output_file <- "./eva-data.csv"
graph_file  <- "./cumulative_eva_graph.png"

#' Read EVA data from a JSON file into a tibble
#'
#' Reads a JSON file containing an array of records (objects) and returns the
#' contents as a tibble for downstream analysis.
#'
#' @param input_file Path to a JSON file (character scalar). The file is expected
#'   to contain a JSON array of objects, e.g. `[{"eva":"1", ...}, {"eva":"2", ...}]`.
#' @return A tibble with one row per JSON record and one column per field.
#' @examples
#' eva_tbl <- read_json_to_dataframe("./eva-data.json")
#' dplyr::glimpse(eva_tbl)
read_json_to_dataframe <- function(input_file) {
  jsonlite::fromJSON(input_file) |>
    tibble::as_tibble()
}

#' Clean an EVA dataframe and write it to CSV
#'
#' Coerces key columns to the expected types (e.g., `eva` to numeric and `date`
#' to POSIXct), drops records missing a usable `duration` or `date`, writes the
#' result to a CSV file, and returns the cleaned dataframe.
#'
#' @param df A data frame or tibble containing EVA records. Expected columns
#'   include `eva`, `date`, and `duration`.
#' @param output_file Path to the output CSV file (character scalar).
#'
#' @return The cleaned dataframe (same class as `df` where practical), suitable
#'   for piping into downstream steps.
#'
#' @examples
#' eva_tbl <- read_json_to_dataframe("./eva-data.json")
#' eva_tbl <- write_dataframe_to_csv(eva_tbl, "./eva-data.csv")
write_dataframe_to_csv <- function(df, output_file) {
  df <- df |>
    dplyr::mutate(
      eva  = as.numeric(eva),
      date = lubridate::ymd_hms(date, quiet = TRUE)
    ) |>
    dplyr::filter(!is.na(duration), duration != "", !is.na(date))
  readr::write_csv(df, output_file)
  df
}

#' Convert a duration string to decimal hours
#'
#' Parses a duration string in `"H:MM"` or `"HH:MM"` format and returns the
#' equivalent number of hours as a numeric scalar.
#'
#' @param duration A character scalar in `"H:MM"` or `"HH:MM"` format,
#'   e.g. `"1:30"` or `"12:45"`.
#' @return A numeric scalar representing the duration in decimal hours.
#' @examples
#' text_to_duration("1:30")  # returns 1.5
#' text_to_duration("0:45")  # returns 0.75
text_to_duration <- function(duration) {
  parts <- stringr::str_split(duration, ":", n = 2, simplify = TRUE)
  as.numeric(parts[, 1]) + as.numeric(parts[, 2]) / 60
}

#' Add a numeric duration column to an EVA dataframe
#'
#' Applies `text_to_duration()` to the `duration` column and appends the result
#' as a new `duration_hours` column.
#'
#' @param df A data frame or tibble containing a `duration` column with values
#'   in `"H:MM"` or `"HH:MM"` format.
#' @return The input dataframe with an additional numeric `duration_hours` column.
#' @examples
#' eva_tbl <- read_json_to_dataframe("./eva-data.json") |>
#'   write_dataframe_to_csv("./eva-data.csv") |>
#'   add_duration_hours()
add_duration_hours <- function(df) {
  df |>
    dplyr::mutate(duration_hours = text_to_duration(duration))
}

#' Plot cumulative EVA time in space and save the figure
#'
#' Computes EVA duration in hours from a `duration` string column (expected format
#' like `"H:MM"` or `"HH:MM"`), calculates cumulative time over chronological
#' `date`, generates a ggplot line chart, saves it to disk, and prints it.
#'
#' @param df A data frame or tibble containing EVA records. Expected columns:
#'   `date` (POSIXct or parseable datetime) and `duration` (character `"H:MM"`).
#' @param graph_file Path to the output image file (character scalar), e.g.
#'   `"./cumulative_eva_graph.png"`.
#'
#' @return Invisibly returns the ggplot object.
#'
#' @examples
#' eva_tbl <- read_json_to_dataframe("./eva-data.json") |>
#'   write_dataframe_to_csv("./eva-data.csv")
#' plot_cumulative_time_in_space(eva_tbl, "./cumulative_eva_graph.png")
plot_cumulative_time_in_space <- function(df, graph_file) {
  df <- df |>
    dplyr::arrange(date) |>
    add_duration_hours() |>
    dplyr::mutate(cumulative_time = cumsum(duration_hours))

  p <- ggplot2::ggplot(df, ggplot2::aes(x = date, y = cumulative_time)) +
    ggplot2::geom_point() +
    ggplot2::geom_line() +
    ggplot2::labs(
      x = "Year",
      y = "Total time spent in space to date (hours)"
    ) +
    ggplot2::theme_minimal()

  ggplot2::ggsave(graph_file, plot = p, width = 9, height = 5, dpi = 300)
  print(p)
  invisible(p)
}

# --- main body of our script ---
# this is what gets run

eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

Even though our code became a bit longer than previously, it is more readable and new functions we added can potentially be reused elsewhere too.

Creating a Main Function


Now we also want to move the main functionality into a main function. There is a common code structure (pattern) for writing a main function in Python:

Some programming languages — Python, for instance — favor a main function so that code can be imported as a module and run directly as a script. This is done to prevent execution on import.

R does not conflate scripts and modules. Assuming the long-term goal is to be able to make our code available via a source() call or to use it from the command line, we will need to modify our code further to make it more modular.

Managing modularity with source()

You might have noticed that our code is now much longer thanks to the addition of the Roxygen comments. Though useful, the Roxygen comments create a lot of visual clutter. One solution is to move the functions to their own .R files. Then we rely on source() to make these functions available in the current R session.

Let’s move the functions we wrote into their own .R files and place them into a folder labeled R/.

  • read_json_to_dataframe() copy into R/read_json_to_dataframe.R
  • write_dataframe_to_csv() copy into R/write_dataframe_to_csv.R
  • plot_cumulative_time_in_space() copy into R/plot_cumulative_time_in_space.R
  • the rest of the functions, we’ll place into R/helpers.R

You will need to share the code below with the learners via copy-and-paste either in shared notes or chat in a virtual training. Then you can highlight and describe the changes.

After tucking away the functions, your code may look like the following:

R

# EVA cumulative time pipeline (tidyverse-first, with reusable functions + roxygen-style docs)

#########################
# load packages
library(tidyverse)
library(jsonlite)
library(lubridate)

##########################
# Files
input_file  <- "./eva-data.json"
output_file <- "./eva-data.csv"
graph_file  <- "./cumulative_eva_graph.png"

#########################
# source functions
source("R/read_json_to_dataframe.R")
source("R/write_dataframe_to_csv.R")
source("R/plot_cumulative_time_in_space.R")
source("R/helpers.R")

# --- main body of our script ---

eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

Make your script available in the command line


Callout

We have been running our code interactively. The opposite of interactive is non-interactive or batch mode. There are reasons why we might want to run our code from the command line. Some examples include:

  • automation: code that can be run on a schedule or triggered by events
  • job schedulers, such as those used on HTC and HPC clusters
  • others?

The critical part is that we now need to address how we pass arguments to our code. If we are running our code interactively and we have a new piece of data — for example, a new EVA dataset was just beamed down to us, let’s call it new-eva-data.json — we could execute our workflow by feeding it to our function directly: read_json_to_dataframe("new_eva_data.json").

In the command line, arguments are not passed that way; they are passed positionally. This is similar to what you do with git to add a file data.csv to the staging area: git add data.csv.

We want our code to be able to do the same.

One important detail: within RStudio you run R (check your console!), and other IDEs also run on top of R. From the command line, however, you don’t use R to run your R script — you use Rscript instead. In other words, Rscript is the command-line interpreter for R scripts.

At this time, running the following will return an error:

Rscript eva_data_analysis.R

Furthermore, the data is hard-coded into the existing code base. Ideally, we would like our pipeline to be able to take new data, something like this:

Rscript eva_data_analysis.R new_eva_data.json

We would need to build some additional code infrastructure for the existing code to be able to take arguments positionally. Minimally, it would look something like this:

R

# load packages

# source calls

# accept arguments positionally
args <- commandArgs(trailingOnly = TRUE)
my_arg <- args[1]

# main block of code

Command-Line Interface to Code


A common way to structure code is to have a command-line interface that allows the passing of various parameters. For example, we can pass the input data file to be read and the output file to be written to as parameters to our script, and avoid hard-coding them. This improves interoperability and reusability of our code, as it can now be run over any data file of the same structure, invoked from the command line terminal, and integrated into other scripts or workflows and pipelines.

For example, another script can produce our input data and be “chained” with our code in a more complex data analysis pipeline. Another use case would be invoking our script in a loop to automatically analyse a number of input data files — compare that to running the script manually over hundreds or thousands of files, which is slow, error-prone, and requires manual intervention.

You will need to share the code below with the learners via copy-and-paste either in shared notes or chat in a virtual training. Then you can highlight and describe the changes.

Our modified code may now look as follows:

R

# EVA cumulative time pipeline (tidyverse-first, with reusable functions + roxygen-style docs)
# Batch mode version

#########################
# load packages
library(tidyverse)
library(jsonlite)
library(lubridate)

##########################
# Files
args <- commandArgs(trailingOnly = TRUE)
input_file  <- args[1]
output_file <- args[2]
graph_file  <- args[3]

# This assumes that when we call our code the arguments are ordered:
# Rscript eva_data_analysis.R <input_file> <output_file> <graph_file>

#########################
# source functions
source("R/read_json_to_dataframe.R")
source("R/write_dataframe_to_csv.R")
source("R/plot_cumulative_time_in_space.R")
source("R/helpers.R")

# --- main body of our script ---

eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

We can now run our script from the command line, passing the JSON input data file, the CSV output file, and the plot file as positional arguments:

BASH

$ Rscript eva_data_analysis.R eva-data.json eva-data.csv cumulative_eva_graph.png

Remember to commit our changes.

BASH

$ git status
$ git add eva_data_analysis.R
$ git commit -m "Add command line functionality to script"

Directory Structure for Software Projects


Expanding on the code structure theme, following conventions on consistent and informative directory structure for your projects will ensure people immediately know where to find things — especially helpful for long-term research projects or when working in teams. The directory structure for organizing your research software project involves creating a clear and logical layout for files and data, ensuring easy navigation, collaboration, and reproducibility.

Below are some good practices for setting up and maintaining a research project directory structure.

  1. Top-level directory
    • Put all files related to a project into a single directory.
    • Choose a meaningful name that reflects the project’s purpose or topic.
  2. Subdirectories — organise the project into clear, well-labeled sub-directories based on the type of content. Common categories include:
    • data/ — store raw and processed data in separate sub-directories to maintain clarity and avoid overwriting your raw data
    • R/ or scripts/ or src/ — for storing your source code
    • results/ — for storing analysis outputs, summary statistics, or any data generated after processing
    • docs/ or man/ — include a detailed project description and documentation on how the project is organised, methodologies, and file dependencies
    • figures/ or plots/ — store all visualizations like charts, graphs, and figures generated from the analysis (these can also go in the results/ directory)
    • references/ — a folder for research papers, articles, or any other literature cited or referenced in the project
  3. Naming conventions
    • Avoid special characters or spaces, as they can cause errors when read by computers; use underscores (_) or hyphens (-) instead.
    • Name files to reflect their contents, version, or date, or use version control to track different versions.
  4. Use version control
    • Code and data should be version controlled; you can also version control manuscripts, results, and more.
    • If data files are too large or contain sensitive information, untrack them using .gitignore.
    • Use tags and releases to mark specific versions of results (a version submitted to a journal, a dissertation version, a poster version, and so on), to avoid version numbers in file names and the proliferation of near-duplicate files.

Below is an example of a project structure that follows these practices:

OUTPUT

project/
├── data/
│   ├── raw/        # untouched source data
│   └── processed/  # cleaned, derived outputs
├── R/              # reusable functions (sourced or packaged)
├── scripts/        # top-level scripts that run the analysis
├── results/        # figures, tables, reports
├── docs/           # documentation, manuscripts
├── project.Rproj
└── README.md
Challenge

Project restructuring (10 min)

Restructure your software project so that input .json data is stored in the data/raw/ directory, processed output in data/processed/, and the .png plot file saved in results/ off the project root.

Remove the current result files eva-data.csv and cumulative_eva_graph.png from the project root (if they exist), as they will be recreated by re-running the code.

Remember to create the results/ and data/processed/ directories before running the code, or your code will fail.

Create data/raw/, data/processed/, and results/, then move the .json data file into the appropriate location.

BASH

mkdir -p data/raw data/processed
mv eva-data.json data/raw
mkdir results

Updated code:

R

# EVA cumulative time pipeline (tidyverse-first, with reusable functions + roxygen-style docs)
# Interactive version

#########################
# load packages
library(tidyverse)
library(jsonlite)
library(lubridate)

##########################
# Files
input_file  <- "data/raw/eva-data.json"
output_file <- "data/processed/eva-data.csv"
graph_file  <- "results/cumulative_eva_graph.png"

#########################
# source functions
source("R/read_json_to_dataframe.R")
source("R/write_dataframe_to_csv.R")
source("R/plot_cumulative_time_in_space.R")
source("R/helpers.R")

# --- main body of our script ---

eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

Remember to commit your latest changes:

BASH

$ git status
$ git add eva_data_analysis.R data/ results/
$ git commit -m "Update project's directory structure"

Code drift


You might have noticed that we have coded ourselves into a corner. We have a version of the code that runs interactively and another that runs from the command line. It is not uncommon to have to add edits as the research work moves along, and having two copies of the same analysis means remembering to make those edits in both files.

A simpler option would be to have code that is sensitive and responsive to the context in which it is being run. This can be implemented with a conditional check that branches to hard-coded filenames when running interactively, or to positional arguments when running in batch mode.

R

# EVA cumulative time pipeline (tidyverse-first, with reusable functions + roxygen-style docs)
# Context-aware version

#########################
# load packages
library(tidyverse)
library(jsonlite)
library(lubridate)

##########################
# Input resolution

args <- commandArgs(trailingOnly = TRUE)

if (interactive()) {
    input_file  <- "data/raw/eva-data.json"
    output_file <- "data/processed/eva-data.csv"
    graph_file  <- "results/cumulative_eva_graph.png"
} else {
    input_file  <- args[1]
    output_file <- args[2]
    graph_file  <- args[3]
}

#########################
# source functions
source("R/read_json_to_dataframe.R")
source("R/write_dataframe_to_csv.R")
source("R/plot_cumulative_time_in_space.R")
source("R/helpers.R")

# --- main body of our script ---

eva_tbl <- read_json_to_dataframe(input_file) |>
  write_dataframe_to_csv(output_file = output_file)

plot_cumulative_time_in_space(eva_tbl, graph_file)

Summary


A good directory structure helps keep a project organised, making it easier to navigate, understand, and maintain. It promotes a clear separation of concerns — related files and components are grouped logically — which simplifies development and reduces the chance of errors.

A well-structured project also supports collaboration, as new contributors can more easily find what they need, and it enables smoother scaling, testing, and deployment as the codebase grows.

Specific to R: passing arguments to a script on the command line requires retooling the code to accept positional arguments via commandArgs(). The command-line interpreter for R scripts is Rscript, not R. And with a simple interactive() check, a single script can serve both contexts without duplication.

At this point, the code in your local software project’s directory should be as in: https://github.com/carpentries-incubator/bbrs-software-project/tree/06-code-correctness

Further Reading


We recommend the following resources for some additional reading on the topic of this episode:

Also check the full reference set for the course.

Key Points
  • Good practices for code and project structure are essential for creating readable, accessible, and reproducible projects.