State, Validation, Modules, and Composition
Ruby source files are converted directly from the English source tree.
en/topic_06_classes_modules_composition
Overview
Why this topic matters ๐ก
This is where students begin designing small cooperating objects instead of isolated functions. The topic introduces internal state, validation, reusable module behavior, and dependency injection through composition.
Learning outcomes ๐ฏ
By the end of this topic, students should be able to:
- use
attr_readerto expose state intentionally; - protect object invariants with private validation methods;
- explain the role of modules for shared behavior;
- use composition and injected collaborators to keep classes focused;
- compare composition with more rigid inheritance-heavy designs.
Assessment focus โ
Students should be able to explain why the object owns its state and why the calculator dependency is injected rather than created internally.
Short Note
Ruby's object model supports very small classes that still do meaningful work. Students should learn that a class is not justified by size alone; it is justified by responsibility, state, and collaboration boundaries.
This topic matters because it introduces two powerful habits:
- keep validation close to the state it protects;
- inject collaborators instead of hard-coding them.
Ruby beauty in this topic:
- state and behavior can live together without boilerplate;
- private helpers express invariants cleanly;
- composition makes testing easier and design more modular.
Ruby caution in this topic:
- modules can hide too much if mixed in carelessly;
- tiny classes are only helpful when their responsibilities are clear.
Reflection prompt:
- Why is dependency injection useful even in a small exercise?
Worked Examples
Example 1: Wallet or account balance ๐ก
A balance-holding object is a strong teaching example because it has real state and clear invariants.
Questions students should ask:
- who owns the balance?
- what counts as invalid input?
- where should validation live?
Example 2: Checkout with injected calculator ๐ก
Checkout logic is often composed from pricing rules, discount engines, tax services, or promotional logic. That makes it a natural composition example.
checkout = Checkout.new(calculator: calculator)
checkout.final_total(cart)ruby
Why this is useful:
- the checkout object does not need to know how totals are computed;
- tests can replace the calculator with a double;
- the design stays open to new pricing strategies.
Cheatsheet
Reader and state
attr_reader :balanceruby
Private validation
private
def validate_positive!(amount)
raise ArgumentError unless amount.positive?
endruby
Constructor injection
def initialize(calculator:)
@calculator = calculator
endruby
Design reminders โ๏ธ
- Let the object protect its own invariants.
- Inject collaborators when behavior may vary or needs isolated testing.
Study Guide
Topic purpose ๐ฏ
Learn how Ruby objects manage state and collaborate with other objects. The goal is to design small, testable objects rather than write procedural code inside classes.
Study sequence ๐ช
- Read
overview.md. - Read
shortnote.md. - Study
worked_examples.mdbefore reviewing the code. - Use
cheatsheet.mdwhile readingexample.rb. - Complete the wallet exercise.
- Complete the checkout exercise with an injected collaborator.
What to notice ๐
- State changes should remain controlled by the owning object.
- Private validation methods communicate invariants clearly.
- Composition reduces coupling and improves testability.
Reflection questions ๐ค
- Why should
Walletvalidate its own amounts? - Why is
Checkoutcleaner when it depends oncalculator:instead of creating one? - When is a module the right reuse mechanism, and when is it too implicit?
Source Files and Tests
Ruby source files are converted directly from the English source tree.
# EXAMPLE CODE
# Topic: topic_06_classes_modules_composition
#
# Purpose:
# - This file demonstrates reference implementation for the concept.
# - It should pass tests from the beginning.
# - Read and understand it before solving exercises.
class BankAccount
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def deposit(amount)
validate_amount!(amount)
@balance += amount
end
private
def validate_amount!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_06_classes_modules_composition
#
# What to do:
# - Implement or improve the class/methods in this file.
# - Read tests in tests/topic_06_classes_modules_composition_spec.rb under the "basic exercise" examples.
# - Make tests pass without breaking the example/advanced sections.
#
# Expected outcome:
# - You can run this topic tests and see all examples green after implementation.
class Wallet
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def top_up(amount)
validate_positive!(amount)
@balance += amount
end
def spend(amount)
validate_positive!(amount)
raise ArgumentError, "insufficient balance" if amount > @balance
@balance -= amount
end
private
def validate_positive!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_06_classes_modules_composition
#
# Academic purpose:
# - Learn composition as a design choice, not just constructor syntax.
# - See how injected collaborators create simpler, more testable classes.
#
# Real-world use case:
# - Checkout systems rarely calculate totals by themselves.
# - Real applications may delegate to tax calculators, discount engines, or pricing services.
# - Injecting a calculator keeps checkout focused on orchestration rather than pricing policy.
#
# Why Ruby is beautiful here:
# - Keyword-based dependency injection is compact and explicit.
# - The collaborator contract can remain small: in this case, just `total(cart)`.
# - Tests can substitute a double easily, making the design discussion visible.
#
# What to do:
# - Complete the challenge behavior requested by the guide.
# - Focus on why composition improves the design, not only how dependency injection works.
# - Use tests in tests/topic_06_classes_modules_composition_spec.rb under the "advanced exercise" examples.
#
# Expected outcome:
# - Advanced tests pass and you can explain the value of injected collaborators.
class Checkout
def initialize(calculator:)
@calculator = calculator
end
def final_total(cart)
@calculator.total(cart)
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_06_classes_modules_composition
#
# Solution idea:
# - The wallet owns the balance state.
# - Both top-up and spend operations reuse the same positivity validation.
# - Spending also checks that the wallet has enough balance before mutating state.
class Wallet
attr_reader :balance
def initialize(balance: 0)
@balance = balance
end
def top_up(amount)
validate_positive!(amount)
@balance += amount
end
def spend(amount)
validate_positive!(amount)
raise ArgumentError, "insufficient balance" if amount > @balance
@balance -= amount
end
private
def validate_positive!(amount)
raise ArgumentError, "amount must be positive" unless amount.positive?
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_06_classes_modules_composition
#
# Solution idea:
# - `Checkout` should not calculate prices by itself.
# - Inject the calculator so checkout stays responsible only for orchestration.
# - The method simply delegates to the collaborator's contract.
class Checkout
def initialize(calculator:)
@calculator = calculator
end
def final_total(cart)
@calculator.total(cart)
end
end
Ruby course source
# This spec is your learning companion for topic_06_classes_modules_composition.
#
# How to use this file:
# 1) Run tests and observe failures/successes.
# 2) Keep EXAMPLE specs green from the beginning.
# 3) Implement BASIC exercise until BASIC specs pass.
# 4) Implement ADVANCED exercise and pass edge cases.
#
# Expected final result:
# - All examples in this file pass.
# - You understand both the concept and the implementation tradeoffs.
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_06_classes_modules_composition" do
describe "EXAMPLE purpose: understand the reference implementation" do
it "manages bank account deposits" do
a = BankAccount.new(balance: 10)
expect(a.deposit(5)).to eq(15)
expect { a.deposit(0) }.to raise_error(ArgumentError)
end
end
describe "BASIC EXERCISE purpose: implement the comparable task" do
it "supports wallet top up and spend validations" do
w = Wallet.new(balance: 20)
end
describe "ADVANCED EXERCISE purpose: solve challenge and edge cases" do
it "uses composition with injected calculator" do
calculator = instance_double("PriceCalculator", total: 120)
checkout = Checkout.new(calculator: calculator)
expect(checkout.final_total([:item])).to eq(120)
end
end
end
end
Ruby course source
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
Ruby course source
Study Prompts
Read the spec first and identify which behaviors belong to the example, basic exercise, and advanced exercise.
Attempt the exercises before opening the answer files, then compare the design choices.