Software Design & Architecture
The last two SOLID principles — Interface Segregation and Dependency Inversion — plus how to model a component's internal behavior (not just its structure), and the practical rules & common mistakes that tie the whole detailed-design chapter together.
Lecture 02 covered the first three letters. This lecture finishes the acronym.
"Classes/methods should not be forced to depend on methods that they do not use." The ISP implies that many small, client-specific interfaces are better than one general-purpose, bloated interface.
A game has enemy characters that can either roam over land, fly, or swim — plus a
SuperCharacter that can do all three. A tempting (but wrong) design puts every
ability on one shared interface:
interface EnemyCharacter {
void fly();
void run();
void swim();
}
// AerialCharacter, TerrestrialCharacter, AquaticCharacter, SuperCharacter
// ALL implement EnemyCharacter
AerialCharacter only needs
fly(), yet is forced to also implement run() and
swim() (usually with empty/no-op bodies). Only SuperCharacter actually
needs the full interface — everyone else is "polluted" with methods they don't use. This is
also an LSP violation: a TerrestrialCharacter can't honestly replace an
EnemyCharacter if calling code expects fly() to work.
interface SuperCharacter {
void fly(); void run(); void swim();
}
interface AerialCharacter extends SuperCharacter { void fly(); }
interface TerrestrialCharacter extends SuperCharacter { void run(); }
interface AquaticCharacter extends SuperCharacter { void swim(); }
// Each client implements ONLY the specific interface it needs
class AerialCharacterClient implements AerialCharacter {
public void fly() { System.out.println("flying"); }
}
Now AerialCharacterClient depends on
exactly one method it actually uses. Only a true "does everything" character needs to implement
the full SuperCharacter contract.
Worker — one bloated interfaceinterface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() { System.out.println("Robot working"); }
public void eat() { } // ❌ Robot doesn't eat!
}
Workable + Eatableinterface Workable { void work(); }
interface Eatable { void eat(); }
class Robot implements Workable { // only what it needs
public void work() { … }
}
class Human implements Workable, Eatable {
public void work() { … }
public void eat() { … }
}
The most flexible systems are those where source-code dependencies refer only to abstractions, never to concretions. High-level modules should not depend on low-level modules — both should depend on abstractions. And: abstractions should not depend on details; details should depend on abstractions.
Buy a charger that works only with Samsung phones — switch to an iPhone, and the charger is useless.
Buy a charger built for the USB abstraction — any phone that speaks USB can use it: Samsung, iPhone, anything. USB is the interface (abstraction).
Switch depends on concrete Fanclass Fan {
void turnOn() { System.out.println("Fan is ON"); }
}
class Switch {
Fan fan = new Fan(); // directly depends on Fan
void press() { fan.turnOn(); }
}
Want a Switch that controls a Light
instead? You have to modify Switch.
Switch depends on a Device interfaceinterface Device { void turnOn(); }
class Fan implements Device { public void turnOn(){…} }
class Light implements Device { public void turnOn(){…} }
class Switch {
Device device;
Switch(Device device) { this.device = device; }
void press() { device.turnOn(); }
}
// usage:
Switch s1 = new Switch(new Fan());
Switch s2 = new Switch(new Light());
❌ Bad — tightly coupled:
class Manager {
Worker worker; // fixed to ONE type
void setWorker(Worker w){ worker = w; }
void manage(){ worker.work(); }
}
✅ Good — depends on abstraction:
interface IWorker { void work(); }
class Manager {
IWorker worker; // any worker type!
void setWorker(IWorker w){ worker = w; }
void manage(){ worker.work(); }
}
Manager can now handle
RobotWorker, AIWorker, InternWorker — anything implementing
IWorker — without a single change to Manager itself.
Component design models two different things, with two different diagrams: internal structure → a class diagram. Internal behavior (how objects talk to each other over time) → a sequence diagram.
Reading this diagram: a timer
expires → ScheduleManager asks the Schedule for the next message →
interprets the message id → based on an alt (alternative) fragment, activates
either the heat sensor or the vibration sensor, never both. This is exactly the kind
of internal timing/collaboration detail a class diagram cannot show on its own.
A checklist of practical habits that make any detailed design — not just OO ones — more likely to succeed.
All design criteria, requirements, and constraints should be known before design starts.
Define the functionalities to be performed at every level of abstraction before deciding which structures implement them.
Do not connect what is independent.
Treat the solution as a black-box first — decide how it interacts with its environment — then design what's inside it, recursively.
Fancy designs are buggier than simple ones — harder to implement, verify, and often less efficient. Complex problems are usually just simple problems huddled together.
Good designers move between high-level view and low-level detail quickly and easily.
A good design is "open-ended" — easy to extend. Don't introduce what's immaterial; don't restrict what's irrelevant.
Before fully implementing a design, build a high-level prototype and verify the design criteria are met.
Abstractions should not depend on details (this is DIP, restated as a general design rule).
Classes within a released component should share common closure — if one needs to change, they all likely need to change too.
It should be impossible to reuse less than the whole component — you reuse the total, not partially.
The dependee must be more stable than the depender. The more stable a component is, the more it should consist purely of abstract classes — a completely stable component is nothing but abstractions.
Diving deep into one part of the design before the rest is even sketched out — only partially satisfies the requirements. Experience is the best cure for this.
Jumping straight from raw requirements text into design details leads to overly constrained, inefficient designs.
Always design for extension and contraction — assume today's requirements are not the final ones.
Over-specifying now over-constrains the implementation later, removing flexibility the developer should have.
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.
EnemyCharacter interface into
Aerial/Terrestrial/Aquatic, all extending a minimal
SuperCharacter base — only the "does everything" type implements it all.
Switch → Device and Manager → IWorker: the concrete class becomes a
constructor/setter parameter of an abstract type, so new implementations plug in without
modifying the depender.