Skip to main content

software_engineering/
return_on_investment.rs

1//! # Return on Investment for Engineering Initiatives
2//!
3//! **Return on investment (ROI)** turns a delivery or reliability
4//! improvement into a financial case decision-makers outside engineering can
5//! weigh directly against competing investments. Build the cost side from
6//! full total cost of ownership, not just upfront development cost, and
7//! build the benefit side from documented, honest outcome evidence rather
8//! than an optimistic first-principles guess. Because both sides carry real
9//! uncertainty, present ROI as a range — a conservative case and an
10//! optimistic case — rather than a single, falsely precise number.
11//!
12//! ## Formula
13//!
14//! ```text
15//! ROI          = (benefit − cost) / cost
16//! ROI range    = (roi(conservative benefit, cost), roi(optimistic benefit, cost))
17//! ```
18//!
19//! ## Why it matters
20//!
21//! A single point estimate that turns out to be wrong damages a case's
22//! credibility far more than a well-explained range the actual outcome
23//! falls within. An analysis process that is genuinely capable of
24//! concluding "this is not worth it," and treats that as a legitimate
25//! result rather than a failure of the analysis, is what keeps ROI
26//! reporting trustworthy over time — an organization known for only ever
27//! producing positive ROI cases quickly loses credibility, because
28//! stakeholders correctly infer the analysis is not independent of the
29//! decision it is meant to inform.
30//!
31//! ## Example
32//!
33//! A platform team's change-failure-rate improvement (see
34//! [`crate::dora_metrics`]) avoids 17 failed deployments a year, each saving
35//! $12,000 in incident cost, for a $204,000 annual benefit against a
36//! $150,000 investment.
37//!
38//! ```rust
39//! use software_engineering::return_on_investment::{roi, roi_range};
40//!
41//! let r = roi(300_000.0, 100_000.0).unwrap();
42//! assert_eq!(r, 2.0);
43//!
44//! let benefit = 204_000.0;
45//! let return_on_investment = roi(benefit, 150_000.0).unwrap();
46//! assert!((return_on_investment - 0.36).abs() < 1e-9);
47//!
48//! // Present the same benefit as a conservative-to-optimistic range instead
49//! // of one falsely precise number.
50//! let (conservative, optimistic) = roi_range(150_000.0, 250_000.0, 150_000.0).unwrap();
51//! assert_eq!(conservative, 0.0);
52//! assert!((optimistic - (2.0 / 3.0)).abs() < 1e-9);
53//! ```
54//!
55//! ## Money
56//!
57//! [`roi`] and [`roi_range`] take plain `f64` amounts. For currency-checked
58//! accounting, use [`rusty_money::Money`] directly rather than through a
59//! wrapper this crate provides — its own `sub` already returns `Result`,
60//! rejecting a benefit and cost quoted in different currencies (USD
61//! against EUR, say) instead of silently treating them as the same unit,
62//! and [`rusty_money::Money::to_f64_lossy`] converts the net benefit and
63//! cost into the same plain proportion [`roi`] returns:
64//!
65//! ```rust
66//! use rusty_money::{Money, iso};
67//! use software_engineering::return_on_investment::roi;
68//!
69//! let benefit = Money::from_major(300_000, iso::USD);
70//! let cost = Money::from_major(100_000, iso::USD);
71//!
72//! // rusty_money's own sub() catches a currency mismatch before it ever
73//! // reaches roi(), which only ever sees plain, same-unit f64 amounts.
74//! let net_benefit = benefit.sub(cost).unwrap();
75//! assert_eq!(net_benefit, Money::from_major(200_000, iso::USD));
76//!
77//! let r = roi(benefit.to_f64_lossy(), cost.to_f64_lossy()).unwrap();
78//! assert!((r - 2.0).abs() < 1e-9);
79//!
80//! // Mismatched currencies are rejected rather than silently subtracted.
81//! let eur_cost = Money::from_major(100_000, iso::EUR);
82//! assert!(benefit.sub(eur_cost).is_err());
83//! ```
84//!
85//! ## Pitfalls
86//!
87//! - **Costing only the upfront investment**, omitting ongoing maintenance,
88//!   infrastructure, and opportunity cost — makes a case look cheaper than
89//!   its full lifetime cost.
90//! - **Inventing a benefit estimate from first principles** rather than
91//!   grounding it in documented, measured, or comparable historical outcome
92//!   data.
93//! - **Presenting a single point estimate** instead of a range — a falsely
94//!   precise number that damages credibility when it turns out wrong.
95//! - **Never reporting a negative or marginal ROI finding** — a sign the
96//!   analysis is not actually independent of the decision it informs.
97//! - **Never closing the loop** — failing to compare actual outcomes against
98//!   the projected range after the fact erodes the organization's future
99//!   forecasting credibility.
100//!
101//! ## Sources
102//!
103//! - Chapter 5.5, Return on investment for engineering initiatives.
104//!
105//! Topic doc: software-engineering-metrics/locales/en-001/chapters/05-05-return-on-investment-for-engineering-initiatives.md
106
107/// Return on investment: net benefit as a proportion of cost.
108///
109/// `(benefit − cost) / cost`. A result of `2.0` means every $1 invested
110/// returns $2 in net profit — a 3x total return.
111///
112/// # Arguments
113///
114/// * `benefit` — the total realized or projected benefit, in any currency
115///   unit.
116/// * `cost` — the total cost, including ongoing total cost of ownership, in
117///   the same unit.
118///
119/// # Returns
120///
121/// The ROI as a proportion (not a percentage), or `None` if `cost` is zero.
122///
123/// # Examples
124///
125/// ```rust
126/// use software_engineering::return_on_investment::roi;
127///
128/// assert_eq!(roi(300_000.0, 100_000.0), Some(2.0));
129/// assert_eq!(roi(1.0, 0.0), None);
130/// ```
131#[must_use]
132pub fn roi(benefit: f64, cost: f64) -> Option<f64> {
133    if cost == 0.0 {
134        return None;
135    }
136    Some((benefit - cost) / cost)
137}
138
139/// ROI expressed as a conservative-to-optimistic range against the same
140/// cost, rather than a single, falsely precise number.
141///
142/// `(roi(conservative_benefit, cost), roi(optimistic_benefit, cost))`.
143///
144/// # Arguments
145///
146/// * `conservative_benefit` — the low, conservative-case benefit estimate.
147/// * `optimistic_benefit` — the high, optimistic-case benefit estimate.
148/// * `cost` — the total cost shared by both cases, in the same unit.
149///
150/// # Returns
151///
152/// A `(conservative_roi, optimistic_roi)` pair, or `None` if `cost` is zero.
153///
154/// # Examples
155///
156/// ```rust
157/// use software_engineering::return_on_investment::roi_range;
158///
159/// let (conservative, optimistic) = roi_range(150_000.0, 250_000.0, 150_000.0).unwrap();
160/// assert_eq!(conservative, 0.0);
161/// assert!((optimistic - (2.0 / 3.0)).abs() < 1e-9);
162/// assert_eq!(roi_range(1.0, 2.0, 0.0), None);
163/// ```
164#[must_use]
165pub fn roi_range(conservative_benefit: f64, optimistic_benefit: f64, cost: f64) -> Option<(f64, f64)> {
166    if cost == 0.0 {
167        return None;
168    }
169    let conservative = (conservative_benefit - cost) / cost;
170    let optimistic = (optimistic_benefit - cost) / cost;
171    Some((conservative, optimistic))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    // "ROI = (benefit − cost) / cost. every $1 invested returns $2 in net
179    // profit, a 3x total return."
180    #[test]
181    fn roi_computes_net_benefit_over_cost() {
182        let r = roi(300_000.0, 100_000.0).unwrap();
183        assert!((r - 2.0).abs() < 1e-9);
184    }
185
186    #[test]
187    fn roi_matches_the_change_failure_rate_worked_example() {
188        let r = roi(204_000.0, 150_000.0).unwrap();
189        assert!((r - 0.36).abs() < 1e-9);
190    }
191
192    #[test]
193    fn roi_is_none_for_zero_cost() {
194        assert_eq!(roi(1.0, 0.0), None);
195    }
196
197    // "Present ROI estimates as a range (a conservative case and an
198    // optimistic case) rather than a single, falsely precise figure."
199    #[test]
200    fn roi_range_returns_conservative_and_optimistic_pair() {
201        let (conservative, optimistic) = roi_range(150_000.0, 250_000.0, 150_000.0).unwrap();
202        assert!((conservative - 0.0).abs() < 1e-9);
203        assert!((optimistic - (2.0 / 3.0)).abs() < 1e-9);
204    }
205
206    #[test]
207    fn roi_range_is_none_for_zero_cost() {
208        assert_eq!(roi_range(1.0, 2.0, 0.0), None);
209    }
210}