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 validation
A 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.