Design Principles
Chapter: 2.2 Design Principles Level: 5 — Mastery (cross-domain synthesis: software design + physics simulation + mathematical modelling) Time Limit: 90 minutes Total Marks: 60
Instructions: Answer all three questions. Code may be written in Python-like pseudocode or valid Python. Where mathematical justification is requested, use notation. Marks are awarded for design reasoning, not merely working code.
Question 1 — Physics Integrator Zoo (SOLID + Strategy + DIP) — [24 marks]
You are building a numerical simulation engine for a projectile under gravity plus optional drag. The equation of motion is
A junior engineer wrote one giant Simulator class whose step() method contains an if method == "euler": ... elif method == "rk4": ... chain, and also hard-codes k, m, g as constants inside the method.
(a) Identify two SOLID principles violated by this design and, for each, name the exact code smell and the consequence for maintainability. [6]
(b) Redesign using the Strategy pattern for the integrator and Dependency Inversion for the force model. Give an interface/abstract-class sketch (Integrator with advance(state, dt, force_fn); a ForceModel abstraction) and show how Simulator depends only on abstractions. [8]
(c) Implement Explicit Euler and RK4 as two Integrator strategies for the 1-D vertical case (, no drag). Then, using , , and one step of s starting from , compute the velocity after one step for both integrators and explain, using the local truncation error order, why they agree here but would differ for a nonlinear force. [6]
(d) Argue whether adding a new VerletIntegrator requires modifying existing classes. Relate your answer explicitly to the Open/Closed Principle. [4]
Question 2 — Decorator Algebra for Signal Filters (DRY + Structural Patterns) — [20 marks]
A DSP pipeline applies transformations to a discrete signal . Each filter is a pure function applied sample-wise, and filters compose.
(a) You need gain (), bias (), and clip (). A colleague writes 6 classes for the 6 orderings of {gain, bias, clip}. State which principle this violates and why, then show how the Decorator pattern reduces this to 3 reusable components plus composition. [6]
(b) Composition of gain-then-bias vs bias-then-gain is not commutative. Prove mathematically that for the linear filters and :
and give the exact difference . Explain what this implies about why Decorator order is part of the contract (not a hidden detail). [8]
(c) For , , input , compute both orderings numerically and confirm your formula from (b). Then explain how the Flyweight pattern could apply if you had 10⁶ identical gain(2) decorators, and estimate the memory-order saving. [6]
Question 3 — YAGNI vs Extensibility Trade-off Proof (KISS + YAGNI + Separation of Concerns) — [16 marks]
A team must choose between Design A (a plugin AbstractFactory framework, 500 LOC, supporting arbitrary future export formats) and Design B (a single to_csv() function, 20 LOC) for a tool that today exports only CSV.
(a) Model maintenance cost as , where = number of formats ever needed, = fixed framework cost, = marginal per-format cost. Suppose , , , (adding a format to B means partial rewrite). Find the break-even at which . [6]
(b) Given the tool has needed exactly one format for 3 years with no roadmap for more, use your result to justify Design B via YAGNI and KISS. State precisely the condition on expected under which YAGNI is the correct engineering call. [6]
(c) Identify one Separation of Concerns improvement that applies to Design B regardless of the YAGNI decision (i.e., a change that is cheap now and never premature). [4]
Answer keyMark scheme & solutions
Question 1 [24]
(a) [6] — 2 marks per principle identified + 1 each for smell/consequence.
- Single Responsibility Principle (SRP):
Simulatorhandles both integration strategy selection and force computation and orchestration. Smell: "God class." Consequence: any change to force law OR integrator forces edits to the same class → merge conflicts, fragile tests. [3] - Open/Closed Principle (OCP): the
if/elifchain must be modified to add a new integrator. Smell: "switch on type." Consequence: not closed for modification; risk of regression each time. [3] - (DIP also acceptable: hard-coded constants = dependency on concretions.)
(b) [8]
class Integrator(ABC):
@abstractmethod
def advance(self, state, dt, force_fn): ... # returns new state
class ForceModel(ABC):
@abstractmethod
def accel(self, state): ... # dv/dt
class Simulator:
def __init__(self, integrator: Integrator, force: ForceModel):
self._integ = integrator # depends on ABSTRACTION (DIP)
self._force = force
def run(self, state, dt, steps):
for _ in range(steps):
state = self._integ.advance(state, dt, self._force.accel)
return state- 3 marks:
Integratorabstraction withadvance. - 3 marks:
ForceModelabstraction; constants now injected, not hard-coded (DIP: high-levelSimulatordepends on interfaces). - 2 marks:
Simulatorconstructor takes abstractions (constructor injection).
(c) [6] 1-D: (constant, no drag). Start , .
- Euler: m/s. [1.5]
- RK4: all four slopes equal since is constant: , so m/s. [1.5]
- Explanation [3]: For a constant derivative the exact solution is linear in ; Euler's local truncation error has coefficient , and RK4's term also vanishes. Both are exact here. For a nonlinear force (e.g. drag ), , so Euler's error appears while RK4 remains — they diverge.
(d) [4]
- Adding
VerletIntegrator= new class implementingIntegrator.advance; no existing class is modified — inject it intoSimulator. [2] - This satisfies OCP: system is open for extension (new strategy) and closed for modification (Simulator, existing integrators untouched). Contrast with the original
if/elif, which required modification. [2]
Question 2 [20]
(a) [6]
- Principle violated: DRY (and KISS). Writing 6 classes duplicates the same 3 transformation logics; a bug fix in
gainmust be repeated. [2] - Decorator solution: 3 leaf/decorator components each wrapping a
Signalsource; composition builds any ordering at runtime. [4]
class Filter(ABC):
def apply(self, x): ...
class Source(Filter):
def apply(self, x): return x
class Gain(Filter):
def __init__(self, inner, a): self.inner, self.a = inner, a
def apply(self, x): return self.a * self.inner.apply(x)
class Bias(Filter):
def __init__(self, inner, c): self.inner, self.c = inner, c
def apply(self, x): return self.inner.apply(x) + self.c
# any ordering: Gain(Bias(Source(), c), a) vs Bias(Gain(Source(), a), c)(b) [8] Compute both compositions:
- . [2]
- . [2]
- Difference: . [2]
- This is nonzero exactly when and ⇒ non-commutative. [1]
- Design implication [1]: Since order changes the output, the sequence of Decorators is semantically significant and must be part of the pipeline's documented contract — it is not an interchangeable implementation detail.
(c) [6] :
- : . [1.5]
- : . [1.5]
- Difference . ✓ Formula confirmed. [1]
- Flyweight [2]: All
Gain(2)decorators share identical intrinsic state (the factor 2). Store one shared flyweight object; per-use extrinsic state (the wrapped signal) passed in. Memory drops from objects to shared + references ≈ six orders of magnitude saving on the intrinsic data.
Question 3 [16]
(a) [6] Set : [6] So the framework pays off only once ~12 distinct formats are needed.
(b) [6]
- Actual/expected (one format, stable 3 years, no roadmap) is far below . [2]
- Therefore ; Design B is ~8.6× cheaper today. YAGNI: don't build the abstract-factory speculative infrastructure for formats that "aren't gonna be needed"; KISS: 20 LOC is trivially understandable and testable. [2]
- Precise condition [2]: YAGNI is correct whenever the expected number of formats . Equivalently, choose B while ; only invest in A once demand credibly exceeds the break-even.
(c) [4]
- Separation of Concerns improvement always worthwhile: split
to_csv()into (1) a data-serialization concern (row→string formatting) and (2) an I/O concern (writing to file/stream). [2] - Rationale: decouples formatting from output destination, enabling unit-testing formatting without touching disk — cheap, non-speculative, and does not build unused format machinery, so it does not violate YAGNI. [2]
[
{"claim":"Euler and RK4 give -9.81 for one step of constant accel -g from v=0, dt=1",
"code":"g=Rational(981,100); dt=1; v0=0; euler=v0+dt*(-g); k=-g; rk4=v0+dt/6*(k+2*k+2*k+k); result=(euler==Rational(-981,100)) and (rk4==Rational(-981,100)) and (euler==rk4)"},
{"claim":"Composition difference (g_a o b_c)-(b_c o g_a) = c(a-1)",
"code":"a,c,x=symbols('a c x'); lhs=a*(x+c); rhs=a*x+c; result=simplify((lhs-rhs)-c*(a-1))==0"},
{"claim":"Numeric check a=2,c=3,x=5 gives 16 and 13 with difference 3",
"code":"a,c,x=2,3,5; g_of_b=a*(x+c); b_of_g=a*x+c; result=(g_of_b==16) and (b_of_g==13) and (g_of_b-b_of_g==c*(a-1))"},
{"claim":"Break-even n* solves 500+15n=60n giving 100/9",
"code":"n=symbols('n'); sol=solve(Eq(500+15*n,60*n),n); result=sol[0]==Rational(100,9)"}
]