Design Principles of Software Applied: Practical Example in Python
Summary: In this article I explain key software design principles (SOLID —with emphasis on SRP and DIP—, DRY, KISS, YAGNI) and show a minimal, practical example in Python: a notification service (email + SMS) designed to be extensible, testable, and easy to understand.
Chosen principles
- SOLID (SRP, OCP, LSP, ISP, DIP) — emphasis on SRP and DIP.
- DRY (Don't Repeat Yourself).
- KISS (Keep It Simple, Stupid).
- YAGNI (You Aren't Gonna Need It).
- Separation of concerns / Testability / Modularity.
Real problem
We need a component that sends notifications to users via multiple channels (e.g., email and SMS). Practical requirements:
- Be able to add new channels (Push, Webhook) without changing core logic.
- Make unit testing easy without real network calls.
- Keep code clear and responsibilities separated.
Design (brief)
- Define an abstraction
Notifier
that represents the contract for sending notifications. - Concrete implementations (
EmailNotifier
,SMSNotifier
) implement the abstraction. -
NotificationService
coordinates notifiers via dependency injection (it does not know concrete implementations). - External clients (SMTP, SMS provider) are encapsulated and mocked in tests.
Example code (suggested files: notifiers.py
, test_notifiers.py
)
```python
# notifiers.py
from abc import ABC, abstractmethod
from typing import List, Dict
class Notifier(ABC):
"""Contract: any Notifier must implement send."""
@abstractmethod
def send(self, to: str, subject: str, body: str) -> bool:
pass
class EmailNotifier(Notifier):
"""SRP: this class only knows how to send emails."""
def __init__(self, smtp_client):
# smtp_client encapsulates real sending logic
self.smtp = smtp_client
def send(self, to: str, subject: str, body: str) -> bool:
return self.smtp.send_email(to, subject, body)
class SMSNotifier(Notifier):
def __init__(self, sms_client):
self.sms = sms_client
def send(self, to: str, subject: str, body: str) -> bool:
# Simplify: use subject as prefix in SMS
text = f"{subject}: {body}"
return self.sms.send_sms(to, text)
class NotificationService:
"""DIP: depends on the Notifier abstraction, not concrete classes."""
def __init__(self, notifiers: List[Notifier]):
self.notifiers = notifiers
def notify_all(self, to: str, subject: str, body: str) -> Dict[str, bool]:
results = {}
for n in self.notifiers:
key = n.__class__.__name__
results[key] = n.send(to, subject, body)
return results
# Mock clients for demo/local
class MockSMTPClient:
def send_email(self, to, subject, body):
print(f"[MockSMTP] Sending email to {to}: {subject} / {body}")
return True
class MockSMSClient:
def send_sms(self, to, text):
print(f"[MockSMS] Sending SMS to {to}: {text}")
return True
if __name__ == "__main__":
email_notifier = EmailNotifier(MockSMTPClient())
sms_notifier = SMSNotifier(MockSMSClient())
svc = NotificationService([email_notifier, sms_notifier])
result = svc.notify_all("user@example.com", "Welcome", "Hi, thanks for signing up.")
print("Result:", result)
```
Tests (example with pytest
— test_notifiers.py
)
```python
# test_notifiers.py
from notifiers import Notifier, NotificationService
class DummyNotifier(Notifier):
def __init__(self):
self.sent = False
def send(self, to, subject, body):
self.sent = True
return True
def test_notification_service_sends_to_all():
a = DummyNotifier()
b = DummyNotifier()
svc = NotificationService([a, b])
res = svc.notify_all("u@x.com", "t", "b")
assert res["DummyNotifier"] is True
assert a.sent and b.sent
```
How the principles apply here
-
SRP (Single Responsibility): Each class has a single responsibility:
EmailNotifier
only sends emails,SMSNotifier
only sends SMS,NotificationService
only orchestrates. -
DIP (Dependency Inversion):
NotificationService
depends onNotifier
(abstraction), not concrete implementations. This allows injecting mocks for testing. -
OCP (Open/Closed): To add
PushNotifier
you do not modifyNotificationService
; create a newNotifier
implementation and register it. -
LSP & ISP: Implementations respect the
send
contract and do not force extra unnecessary methods. - DRY: Transport-specific logic is encapsulated, avoiding duplication.
- KISS / YAGNI: Simple design that covers current requirements; no retries, batching, or added complexity until required.
- Testability / Modularity: By injecting dependencies and using mock clients, tests are deterministic and fast.
How to run locally
-
Create a virtual environment (recommended):
python -m venv .venv source .venv/bin/activate # Linux/macOS .venv\Scripts\activate # Windows pip install pytest
-
Run the demo:
python notifiers.py
-
Run tests:
pytest -q
Conclusion
This minimal example demonstrates how to apply design principles to build a notification component that is extensible, testable, and maintainable. By prioritizing abstractions, separation of concerns, and simplicity, the code is prepared to grow (add channels, instrumentation, retries) without becoming fragile.
Top comments (0)