3.0 Behavior and Dispatch
The four patterns in this chapter address how methods communicate their intent,
how conditional logic is eliminated through types, and how behavior is varied
without subclassing. They operate at the boundary between individual method
contracts and class design.
3.1 Command-Query Separation
Problem: a method both changes state and returns a value.
Callers cannot reason about which calls are safe to repeat, reorder, or skip.
Reading code that queries state through calls with side effects requires tracking
implicit mutations.
Solution: separate every method into one of two kinds. A
command changes state and returns nothing (or only a status). A
query returns a value and causes no observable side effects. Meyer:
asking a question should not change the answer. Beck applies this at
the implementation level: name commands as imperatives
(add_item, reset), name queries as nouns or predicates
(count, is_empty).
// Violation: pop() both removes an item and returns it
fn pop(&mut self) -> Option<T>
// CQS-compliant separation
fn peek(&self) -> Option<&T> // query: returns top item, no mutation
fn remove_top(&mut self) // command: removes top item, returns nothing
// In Rust, the standard library violates CQS intentionally for ergonomics:
// Vec::pop() is a well-known, documented exception.
// The principle guides new API design; it does not forbid pragmatic exceptions.
Command-Query Separation - Notes
| Notes |
| Benefit |
Queries are referentially transparent - their result depends only on state,
not on how many times they are called. This makes code easier to test,
reason about, and optimize (queries can be cached or reordered).
|
| Pragmatic exceptions |
Some operations are inherently atomic (dequeue an item from a concurrent
queue). CQS may be violated where the alternative requires holding a lock
across two calls. Document the exception; do not make it a habit.
|
| Naming signal |
If a method name contains a verb and returns a value, it is
probably violating CQS. get_and_clear(), fetch_next(),
update_and_return() are signals to review.
|
3.2 Replace Conditional with Polymorphism
Problem: the same type-checking conditional - if type == A
... else if type == B ... or a switch on a type tag - appears
in multiple methods. Adding a new type requires finding and updating every copy.
Solution: define a method on a shared interface and implement it
differently for each type. The caller dispatches through the interface; the
conditional disappears into the dispatch mechanism. Fowler:
move each leg of the conditional into an overriding method in a subclass
(or trait implementor). Make the original method abstract.
// Before: type-dispatching conditional repeated in every rendering method
fn render_shape(shape: &Shape, canvas: &mut Canvas) {
match shape.kind {
ShapeKind::Circle => render_circle(shape, canvas),
ShapeKind::Rectangle => render_rectangle(shape, canvas),
ShapeKind::Triangle => render_triangle(shape, canvas),
}
}
fn bounding_box(shape: &Shape) -> Rect {
match shape.kind { ... } // same match, again
}
// After: each type carries its own behavior
trait Shape {
fn render(&self, canvas: &mut Canvas);
fn bounding_box(&self) -> Rect;
}
struct Circle { center: Point, radius: f64 }
struct Rectangle { origin: Point, size: Size }
struct Triangle { vertices: [Point; 3] }
impl Shape for Circle { fn render(..) { .. } fn bounding_box(..) { .. } }
impl Shape for Rectangle { fn render(..) { .. } fn bounding_box(..) { .. } }
impl Shape for Triangle { fn render(..) { .. } fn bounding_box(..) { .. } }
Adding a Polygon type now requires only a new impl Shape for Polygon.
No existing code changes. The match statements that scattered logic across the codebase
are gone.
Replace Conditional - Notes
| Notes |
| When to apply |
When the same conditional on a type tag or enum appears in three or more
methods. A single conditional on an enum is normal; the same conditional
in many places is a sign that behavior belongs on the type.
|
| Rust enums |
Rust enums with match are often the right choice when the
set of variants is closed and all variants are known at compile time.
The compiler enforces exhaustive matching. Use trait objects when
new types must be added without recompilation.
|
| Limit |
Not every conditional needs polymorphism. A single boolean flag that
changes one behavior in one method does not justify an interface split.
|
3.3 Delegation over Inheritance
Problem: a subclass inherits a large interface from a parent but
only needs a small part of it. The subclass overrides most inherited behavior to
no-ops or errors. Changes to the parent break subclasses that depend on inherited
methods. Testing the subclass requires instantiating the parent.
Solution: hold a reference to a collaborator and delegate the
needed operations to it. The class exposes only the methods it actually needs;
it is not bound to the parent's full interface. The GoF principle: favor
object composition over class inheritance.
// Inheritance: Stack inherits from List and inherits all list operations
// - includes remove_at(), insert_at(), etc. that violate stack semantics
class Stack : public List { ... }
// Delegation: Stack holds a List and exposes only push/pop/peek
struct Stack<T> {
storage: Vec<T> // holds a Vec but does not inherit from it
}
impl<T> Stack<T> {
fn push(&mut self, item: T) { self.storage.push(item) }
fn pop(&mut self) -> Option<T> { self.storage.pop() }
fn peek(&self) -> Option<&T> { self.storage.last() }
fn is_empty(&self) -> bool { self.storage.is_empty() }
}
The delegation version exposes exactly the stack interface. Callers cannot call
list operations on a stack. Changing the internal storage to a LinkedList
requires one field change - no interface impact.
Delegation - Notes
| Notes |
| When inheritance is right |
True is-a relationships where the subtype genuinely satisfies the Liskov
Substitution Principle - it can be used wherever the base type is used
without surprising the caller. If you would have to override methods to
prevent their use, delegation is better.
|
| Rust |
Rust has no class inheritance. Delegation through held fields is the only
option; the language enforces the pattern by design. Trait implementations
on wrapper types provide the needed interface.
|
| C# / Python |
Forwarding properties or __getattr__ reduce the boilerplate of
delegation when many methods need to be forwarded unchanged.
|
3.4 Template Method
Problem: several procedures share the same overall structure but
differ in specific steps. Duplicating the skeleton in each procedure means that
changes to the overall flow must be made in every copy.
Solution: define the skeleton once in a template method that calls
abstract or overridable steps. Each variant implements only the steps that differ.
Beck applies this at the implementation level: the steps can be virtual methods,
injected callables, or trait implementations - the key is that the skeleton
is written once.
// Template method using injected steps (functional style)
struct ReportPipeline {
fetch: Box<dyn Fn() -> Vec<Row>>,
filter: Box<dyn Fn(Vec<Row>) -> Vec<Row>>,
render: Box<dyn Fn(Vec<Row>) -> String>,
}
impl ReportPipeline {
fn run(&self) -> String {
let data = (self.fetch)();
let filtered = (self.filter)(data);
(self.render)(filtered)
}
}
// Variant A: CSV output
ReportPipeline { fetch: db_fetch, filter: active_only, render: csv_render }
// Variant B: HTML output
ReportPipeline { fetch: db_fetch, filter: active_only, render: html_render }
The pipeline structure - fetch, filter, render - is written once. Variants differ
only in the step implementations they supply. Adding an HTML variant requires no
changes to the skeleton.
Template Method - Notes
| Notes |
| Inheritance vs. injection |
The classic GoF form uses virtual method overrides in subclasses.
The modern idiomatic form injects step implementations as closures,
callbacks, or trait implementations - avoiding a subclass hierarchy
for what is essentially a parameterization.
|
| vs. Strategy |
Strategy replaces the entire algorithm; Template Method replaces individual
steps within a fixed skeleton. Use Template Method when the skeleton is
stable and only specific steps vary; use Strategy when the algorithm as a
whole is interchangeable.
|
| Invariants |
The template method can enforce invariants around the injected steps - for
example, always logging before and after each step, or wrapping the
computation in a transaction - regardless of what the steps do.
|
3.5 References
Behavior and Dispatch References