Software Design & Architecture
This is where detailed design gets its hands dirty: defining the internal classes of each component, then using five battle-tested principles — SOLID — to stop that internal design from rotting the moment requirements change.
Component design (a.k.a. component-level design) is the detailed-design task of defining the internal structure and behavior of the components that were already identified during architecture.
Architecture defines high-level components and interfaces — e.g. a ClientSystem
talking to a ServerSystem through a ServerInterface. You know the
boxes and the arrows, not the code.
Component design refines those same boxes into classes, interfaces, and types — enough detail that a developer could start typing code immediately.
ClientSystem becomes a real Client
class; ServerSystem becomes a ConcreteServer class that
implements a ServerInterface.
During component design, the internal data structures, algorithms, interface details, and communication mechanisms for every component get defined. That's why this single task provides the most significant mechanism for determining the functional correctness of the whole system — and it lets you evaluate alternative solutions before construction even begins, when changes are still cheap.
Before you can design components in OO, you need four building blocks fluent in your fingers: classes/objects, abstract classes & interfaces, inheritance, and polymorphism. The refresher below is deliberately compressed — you've seen this in programming courses, this is just the "why it matters for design" angle.
Four concepts, one mnemonic: C.A.I.P. — Classes → Abstract classes/Interfaces → Inheritance → Polymorphism. Each one exists to let you write code against a promise instead of a specific implementation.
A class with the abstract keyword. It must have at least one
abstract method (no body) but can also have fully-implemented
concrete methods. It cannot be instantiated — you can only inherit from it.
private/protected/public,
final, static, and normal instance fields
abstract class Animal {
// concrete — has a body, inherited as-is or overridden
public void eat() { System.out.println(name + " is eating.."); }
// abstract — no body, subclass MUST implement it
public abstract void makeSound();
}
class Dog extends Animal {
@Override
public void makeSound() { System.out.println(name + " says: Woof!"); }
}
new Animal(...) is illegal (abstract), but
new Dog(...) is fine — it inherited eat() for free and was
forced to implement makeSound().
An interface is a special kind of abstract class: a blueprint with
zero concrete methods. It cannot be instantiated, never holds instance variables
(only public static final constants), and any class that
implements it can be instantiated as long as it fills in every method.
| Feature | Abstract Class | Interface |
|---|---|---|
| Methods | Abstract + concrete | Originally abstract only |
| Variables | Instance variables allowed | Only constants |
| Inheritance | Extend only one | Implement multiple |
| Coupling | Tighter — carries implementation | Looser — signatures only |
UML calls this a Generalization relationship (hollow triangle arrow pointing to the parent). A subclass automatically gets every field and method of its superclass, and can override any of them.
public class Animal {
public void sound(){ System.out.println("Animal is making a sound"); }
}
public class Cat extends Animal {
@Override
public void sound(){ System.out.println("Miaw"); }
}
You can point a variable of the parent type at a child object, and calling an overridden method runs the child's version — decided at runtime, not compile time.
Animal ah = new Horse(); // declared as Animal, actually a Horse ah.sound(); // runs Horse's override → "Neigh" Animal ac = new Cat(); ac.sound(); // runs Cat's override → "Miaw"
When designing at the component level, five principles keep designs from becoming complex, hard to reuse, and painful to change. Together they spell SOLID.
"A module should be responsible to one, and only one, user or stakeholder." A class should do only one job. If it does more than one, every unrelated reason-to-change forces you to open a class that has nothing to do with that change.
Student classclass Student {
void storeData() { /* save student data */ }
void calculateGrades(){ /* calculate grades */ }
void printReport() { /* print report */ }
}
This class has three reasons to change: storage format, grading logic, and report format. Switch from paper reports to PDF? Open this class. Switch the database from MySQL to MongoDB? Open this same class again.
class StudentData { void storeData() {…} }
class GradeCalculator { void calculateGrades() {…} }
class ReportPrinter { void printReport() {…} }
Each class now has one job = one reason to change → easier to maintain, test, and extend independently.
Person + email validationA Person class that validates its own email is doing two jobs: being a person, and
knowing the rules of a valid email address (that's not "person behavior").
Person has name,
surname, email, and its own validateEmail() regex
check baked in.
Email class. Person now just holds a
name, surname, and an Email object — it no longer
needs to know anything about regex rules.
Giving a class a single responsibility (for a single actor/stakeholder) makes it, by default, easier to see what it does and easier to extend or improve later.
"Software designs should be open to extension, but closed for modification." Code that already works should stay untouched. New requirements get satisfied by adding new code, not by editing old code that was already tested and shipped.
A fictional game has several types of terrestrial (land) characters, and new character types are expected in the future.
// v1 — violates OCP class TerrestrialCharacter { virtual void draw() { /* draw the terrestrial character */ } virtual void run() { /* make the character run */ } } class GameEngine { void add(TerrestrialCharacter* pCharacter) { pCharacter->draw(); pCharacter->run(); } }
GameEngine depends directly on
the concrete class TerrestrialCharacter. Add a new
AerialCharacter that needs to fly() instead of run(), and
you're forced to modify GameEngine.add(...) to branch on the character's
type. That's a modification to code that already worked — a straight-up OCP violation.
Design principle at play: Encapsulate Variation — find the part of the design that's likely to change (how a character moves) and hide it behind polymorphism.
// v2 — adheres to OCP public interface Character { void draw(); void move(); // generalized: run, fly, swim, etc. } public class TerrestrialCharacter implements Character { public void draw() { System.out.println("Drawing terrestrial character..."); } public void move() { System.out.println("Running on the ground..."); } } public class AerialCharacter implements Character { public void draw() { System.out.println("Drawing aerial character..."); } public void move() { System.out.println("Flying in the sky..."); } } public class GameEngine { public void add(Character character) { character.draw(); character.move(); // engine code never branches on type } }
AquaticCharacter that swims? Just write a new class that
implements Character. GameEngine is never touched again —
extended, not modified.
public class AquaticCharacter implements Character {
public void draw() { System.out.println("Drawing aquatic character..."); }
public void move() { System.out.println("Swimming in the water..."); }
}
engine.add(new AquaticCharacter()); // engine.add(...) itself: unchanged
One honest caveat about OCP: no design is 100% closed forever — at some point, some code has to be readily editable for any real system. The real goal of OCP is to locate the parts of the software that are likely to vary, and encapsulate + implement those variations through polymorphism, so the rest of the system stays closed.
"Objects should be replaceable with instances of their subtypes without altering the correctness of that program." Any class derived from a base class must honor the implied contract between that base class and whatever code uses it.
If a program is type-correct for the super type, it must remain type-correct for the subtype too — method signatures in the subtype must match the base class (same/compatible parameter and return types).
Calls to base-class methods must stay valid reasoning even when they actually run overridden subtype code. Subtypes may weaken preconditions (require less) and strengthen postconditions (guarantee more) — never the reverse.
Base class:
class Bird {
fly(): void { console.log("Flying"); }
}
❌ Violation:
class Penguin extends Bird {
fly(): void {
throw new Error("Penguins can't fly!");
}
}
bird.fly() expecting a
Bird will crash the instant that Bird is actually a
Penguin. Substituting the subtype changed the program's correctness — a direct LSP
violation. A Sparrow extends Bird that really flies would be a perfectly valid,
LSP-honoring subtype.
Square is not a proper subtype of Rectangle: a
Rectangle's height and width are independently mutable, but a
Square's must always change together. A User that thinks
it's talking to a Rectangle (call setH() then check the area) gets
confused the moment the object is actually a Square.
Lesson: a substitutability violation, even a "simple" one, can pollute an entire architecture with extra defensive-checking mechanisms.
A Billing app calls license.calcFee() on a License
reference. PersonalLicense and BusinessLicense each calculate the
fee differently, but Billing never needs to know which one it has.
This example satisfies both OCP (new license types can be added
without touching Billing) and LSP (both subtypes are fully substitutable
for License).
Tap a card to flip it. Good for a 5-minute recap right before an exam.
Self-check practice written from this lecture's content — not official past-paper questions.