Principles of Good Design
Expert · 18 min read · ▶ live playground · ✦ checkpoint
The hardest code to change is not always the messiest-looking code. Sometimes it is neat, formatted, tested code where every small change tugs on five other places. Good design is how you keep future changes from pulling the whole program off the bench.
Design means change has a place to go
Design is the set of choices that decide where code lives, what each part knows, and how parts talk to each other. Architecture is design at the larger system level: the big separations between core rules, storage, screens, network calls, and outside tools.
Think of design like arranging a workshop bench. A screwdriver can be beautiful, but if every tool is tied together with string, you still cannot pick one up cleanly. Good design keeps related tools close and unrelated tools separate.
Refactoring means changing code structure without changing what the program does. It is the habit of improving the bench while the finished chair still has four legs.
SOLID, translated into Python
SOLID is a set of five object-oriented design principles, meaning five guidelines for keeping classes and functions changeable. You do not recite them to win arguments. You use them when code starts resisting change.
Here is the Python version:
- Single Responsibility Principle: one part should have one main reason to change.
- Open/Closed Principle: add new behavior by plugging in a new piece, not editing tested code every time.
- Liskov Substitution Principle: a replacement object should keep the public behavior promise of the thing it replaces.
- Interface Segregation Principle: prefer small public behavior promises over one huge "do everything" object.
- Dependency Inversion Principle: main program rules should depend on small behavior promises, not on one concrete storage, printing, or network tool.
That is a lot of names. Watch how ordinary the code can be.
class MemoryJournal:
def __init__(self):
self.entries = []
def add(self, entry):
self.entries.append(entry)
def steady_points(minutes):
return minutes * 2
def finish_practice(player, minutes, scoring_rule, journal):
points = scoring_rule(minutes)
entry = {"player": player, "minutes": minutes, "points": points}
journal.add(entry)
return f"{player} earned {points} points"
journal = MemoryJournal()
print(finish_practice("Maya", 15, steady_points, journal))
print(journal.entries)Maya earned 30 points[{'player': 'Maya', 'minutes': 15, 'points': 30}]
The scoring rule has one job. The journal has one job. finish_practice() depends on two tiny promises: "I can call this scoring rule" and "this journal has .add()." That is Single Responsibility, Open/Closed, Interface Segregation, and Dependency Inversion wearing normal Python clothes.
Liskov shows up when you swap in another journal. If a new journal has an .add(entry) method and stores the entry honestly, the rest of the code should not care which journal it got. That is the same "different devices with the same Play button" idea from polymorphism.
Composition over inheritance
Composition means building an object by giving it other objects or functions to use. Inheritance says "this class is a kind of that class." Composition says "this class has a helper."
Inheritance is useful when the relationship is truly "is-a": a Book is a kind of LibraryItem. Composition is better when you want to swap a behavior without creating a family tree of subclasses.
class StreakBonus:
def bonus_for(self, days):
return days * 5
class NoBonus:
def bonus_for(self, days):
return 0
class PracticeReport:
def __init__(self, bonus_rule):
self.bonus_rule = bonus_rule
def total_points(self, minutes, streak_days):
base_points = minutes * 2
return base_points + self.bonus_rule.bonus_for(streak_days)
report = PracticeReport(StreakBonus())
print("with streak:", report.total_points(20, 3))
quiet_report = PracticeReport(NoBonus())
print("no bonus:", quiet_report.total_points(20, 3))with streak: 55no bonus: 40
No subclass explosion needed. You do not need StreakPracticeReport, NoBonusPracticeReport, WeekendStreakPracticeReport, and so on. The report keeps its job, and the bonus rule changes independently.
Coupling and cohesion
Coupling is how tightly two parts of code depend on each other's details. High coupling feels like those strings between workshop tools: pull the score calculator, and the email sender falls over.
Cohesion is how strongly the code in one place belongs together. High cohesion feels like one drawer holding one kind of tool. Low cohesion is a drawer with batteries, receipts, a spoon, and one missing screw.
This class has low cohesion and high coupling. It calculates points, formats a message, prints, and stores data all in one place:
class PracticeManager:
def __init__(self):
self.saved_lines = []
def finish(self, player, minutes):
points = minutes * 2
message = f"{player} earned {points} points"
print(message)
self.saved_lines.append(f"{player},{minutes},{points}")
manager = PracticeManager()
manager.finish("Noor", 12)
print(manager.saved_lines)Noor earned 24 points['Noor,12,24']
This is not evil code. It is beginner code that has outgrown its first shape. If points change, printing changes, or storage changes, the same class gets edited for unrelated reasons.
Refactoring means moving those reasons to better homes.
Try it - separate the reasons to change
Run this version, then change scoring_rule from steady_points to bonus_points. The storage and message code do not move.
This version has more small pieces, but the pieces have cleaner jobs:
build_practice_entry()handles the core rule.format_summary()handles display text.MemoryJournalhandles storage.finish_practice()coordinates the pieces.
Small pieces are not automatically better. If you split code until every line gets its own function, you create noise. The goal is not more parts. The goal is parts with honest reasons to change.
Clean architecture starts with direction
Clean architecture is a way to arrange code so the core rules do not depend on outside details like databases, web frameworks, files, or email services. Picture rings around your program: core rules in the center, edge code, meaning code that talks to outside tools, on the outside ring.
The center should answer questions like "How many points did the player earn?" The edge should answer questions like "Where do we print, save, or send this?"
def calculate_points(minutes, streak_days):
return minutes * 2 + streak_days * 5
def build_result(player, minutes, streak_days):
points = calculate_points(minutes, streak_days)
return {"player": player, "points": points}
def show_result(result):
print(f"{result['player']} earned {result['points']} points")
result = build_result("Iris", 18, 2)
show_result(result)Iris earned 46 points
In a real app, the edge might be a CLI command, a Flask route, a database table, a JSON file, or a background job. The core rule should still be small enough to test without starting the whole outside world.
That is the design thread across this lesson: keep the parts that change for different reasons from melting together.
Checkpoint
Answer all three to mark this lesson complete
Good design is not about making code look impressive. It is about making the next honest change less dangerous. Next, you shift from code structure to professional practice: reading large codebases, reviewing changes, and staying current without getting buried.