Notes and exercises for learning design patterns
A small ecommerce app has this implementation:
class OrderService:
def place_order(self, order):
# Validate order
if not order.items:
raise ValueError("Order must contain at least one item")
if order.total <= 0:
raise ValueError("Order total must be positive")
# Save order
database = MySQLDatabase()
database.save(order)
# Send notification
if order.customer.preferred_notification == "email":
email_sender = EmailSender()
email_sender.send(
order.customer.email,
"Order confirmed",
"Thanks for your order!"
)
elif order.customer.preferred_notification == "sms":
sms_sender = SmsSender()
sms_sender.send(
order.customer.phone_number,
"Thanks for your order!"
)
elif order.customer.preferred_notification == "push":
push_sender = PushSender()
push_sender.send(
order.customer.device_id,
"Order confirmed"
)
# Track analytics
analytics = AnalyticsClient()
analytics.track("order_placed", {
"order_id": order.id,
"total": order.total,
"customer_id": order.customer.id
})
Full original implementation file: exercise1.py
Fixed implementation file: exercise1_fixed.py
Critique this design using the SOLID principles.
Try to answer:
This design works for a small prototype, but it mixes too many responsibilities and hard-codes too many implementation details into OrderService.
OrderService has become an orchestration method, validation layer, persistence layer, notification dispatcher, analytics tracker, and dependency creator all at once.
The initial critique is correct.
class OrderService:
def place_order(self, order):
# validate
# save
# notify
# track analytics
This class has several reasons to change:
| Reason for change | Code affected |
|---|---|
| Order validation rules change | OrderService |
| Database changes | OrderService |
| Notification rules change | OrderService |
| Analytics event schema changes | OrderService |
| Order placement business flow changes | OrderService |
That is a strong SRP smell.
A better split might be:
class OrderValidator:
def validate(self, order):
...
class OrderRepository:
def save(self, order):
...
class NotificationService:
def notify_order_confirmed(self, order):
...
class AnalyticsTracker:
def track_order_placed(self, order):
...
class OrderService:
def __init__(self, validator, repository, notifier, analytics):
self.validator = validator
self.repository = repository
self.notifier = notifier
self.analytics = analytics
def place_order(self, order):
self.validator.validate(order)
self.repository.save(order)
self.notifier.notify_order_confirmed(order)
self.analytics.track_order_placed(order)
Important nuance: OrderService can still coordinate the workflow. SRP does not mean place_order() can only call one thing. It means it should not personally own all the details.
This part is the obvious OCP violation:
if order.customer.preferred_notification == "email":
...
elif order.customer.preferred_notification == "sms":
...
elif order.customer.preferred_notification == "push":
...
Every new notification channel requires editing OrderService.
For example, adding WhatsApp, Slack, or in-app messages means modifying existing code that already works.
A better approach would be to use a common interface:
class NotificationChannel:
def send_order_confirmation(self, order):
raise NotImplementedError
Then implementations:
class EmailNotificationChannel(NotificationChannel):
def send_order_confirmation(self, order):
self.email_sender.send(
order.customer.email,
"Order confirmed",
"Thanks for your order!"
)
class SmsNotificationChannel(NotificationChannel):
def send_order_confirmation(self, order):
self.sms_sender.send(
order.customer.phone_number,
"Thanks for your order!"
)
class PushNotificationChannel(NotificationChannel):
def send_order_confirmation(self, order):
self.push_sender.send(
order.customer.device_id,
"Order confirmed"
)
Then selection can happen elsewhere:
class NotificationService:
def __init__(self, channels):
self.channels = channels
def notify_order_confirmed(self, order):
channel = self.channels[order.customer.preferred_notification]
channel.send_order_confirmation(order)
Now adding WhatsApp means adding a new class and registering it, not editing OrderService.
This is the DIP smell:
database = MySQLDatabase()
email_sender = EmailSender()
sms_sender = SmsSender()
push_sender = PushSender()
analytics = AnalyticsClient()
OrderService directly creates concrete low-level dependencies.
That makes it tightly coupled to:
MySQL
EmailSender
SmsSender
PushSender
AnalyticsClient
Problems:
OrderService knows exactly which concrete classes to use.A better version:
class OrderService:
def __init__(self, order_repository, notification_service, analytics_tracker, order_validator):
self.order_repository = order_repository
self.notification_service = notification_service
self.analytics_tracker = analytics_tracker
self.order_validator = order_validator
def place_order(self, order):
self.order_validator.validate(order)
self.order_repository.save(order)
self.notification_service.notify_order_confirmed(order)
self.analytics_tracker.track_order_placed(order)
Now OrderService depends on abstractions: something that saves orders, something that sends notifications, and something that tracks analytics.
It does not care whether the database is MySQL, PostgreSQL, DynamoDB, or an in-memory fake.
There is no explicit large interface here, so we cannot say it definitely violates ISP.
But there is a possible risk.
Suppose you created one big dependency like this:
class OrderInfrastructure:
def save(self, order):
...
def send_email(self, ...):
...
def send_sms(self, ...):
...
def send_push(self, ...):
...
def track(self, ...):
...
That would likely violate ISP because clients would depend on methods they do not use.
A better design would use smaller interfaces:
class OrderRepository:
def save(self, order):
...
class OrderNotifier:
def notify_order_confirmed(self, order):
...
class OrderAnalytics:
def track_order_placed(self, order):
...
So for this specific code, the critique is:
No direct ISP violation is visible, but the fix should avoid creating one giant manager or infrastructure interface.
There is no inheritance or subtype relationship shown, so there is no direct LSP violation in the current code.
But LSP matters when you refactor.
For example, this could become problematic:
class NotificationChannel:
def send_order_confirmation(self, order):
raise NotImplementedError
class SmsNotificationChannel(NotificationChannel):
def send_order_confirmation(self, order):
if not order.customer.phone_number:
raise Exception("Cannot send SMS")
That may or may not violate LSP depending on the contract.
If the parent contract says “send the order confirmation,” but some implementations fail for normal orders, callers now need to know implementation-specific details.
A better design might make eligibility explicit:
class NotificationChannel:
def can_send(self, order):
raise NotImplementedError
def send_order_confirmation(self, order):
raise NotImplementedError
Or validate notification preferences before calling the channel.
So the critique is:
No immediate LSP issue, but any notification abstraction should define a contract that all notification channels can honestly satisfy.
place_order() does several external things:
writes to database
sends customer notification
tracks analytics
That makes the method harder to reason about. If notification sending fails, what happens to the order? Was it already saved? Should analytics still run?
The current design has unclear failure behavior.
Example questions:
The order is saved before notifications and analytics.
database.save(order)
# then send notification
# then track analytics
That might be okay, but the business rule should be clear.
Usually, saving the order is core. Notification and analytics are secondary side effects. Many systems would avoid failing the whole order just because analytics failed.
A more robust design may use events:
class OrderService:
def place_order(self, order):
self.validator.validate(order)
self.repository.save(order)
self.events.publish(OrderPlaced(order))
Then separate handlers respond:
class SendOrderConfirmation:
def handle(self, event):
...
class TrackOrderPlacedAnalytics:
def handle(self, event):
...
This can be clean when the system grows. For a small app, direct calls may be enough.
This code:
order.customer.email
order.customer.phone_number
order.customer.device_id
means OrderService knows the data requirements for every notification type.
That is another reason notification logic should be moved elsewhere.
OrderService should probably say:
self.notifier.notify_order_confirmed(order)
Not:
if email, use email address
if sms, use phone number
if push, use device id
With the current design, a unit test for order validation might accidentally touch:
MySQLDatabase
EmailSender
SmsSender
PushSender
AnalyticsClient
That is a big testing smell.
A cleaner design lets you test the order flow with fakes:
fake_repository = FakeOrderRepository()
fake_notifier = FakeNotifier()
fake_analytics = FakeAnalytics()
validator = OrderValidator()
service = OrderService(
validator,
fake_repository,
fake_notifier,
fake_analytics
)
Then you can assert:
assert fake_repository.saved_order == order
assert fake_notifier.confirmed_order == order
assert fake_analytics.tracked_order == order
The validation itself is not necessarily bad:
if not order.items:
raise ValueError("Order must contain at least one item")
if order.total <= 0:
raise ValueError("Order total must be positive")
For a very small app, simple inline validation can be fine.
The workflow order is also understandable:
validate order
save order
notify customer
track analytics
That sequence is clear.
The problem is not the existence of these steps. The problem is that OrderService owns all implementation details for every step.
| Principle | Critique |
|---|---|
| SRP | Violated. OrderService validates, saves, notifies, tracks analytics, and creates dependencies. |
| OCP | Violated. Adding a new notification type requires editing OrderService. |
| DIP | Violated. OrderService directly creates MySQLDatabase, senders, and analytics client. |
| ISP | Not directly visible, but avoid replacing this with one giant interface. |
| LSP | Not directly visible because no inheritance or subtyping is shown. Be careful when introducing notification abstractions. |
The strongest issues are SRP, OCP, and DIP. The main extra critique points are failure behavior, testability, unclear transaction boundaries, and the fact that ISP/LSP are not obvious violations from this exact snippet.