Notes and exercises for learning design patterns
class LoggingDecorator(NotifierDecorator):
def send(self, recipient: str, message: str) -> None:
print(f"SENDING notification to {recipient}")
self._wrapped.send(recipient, message)
print(f"SENT notification to {recipient}")
class RetryDecorator(NotifierDecorator):
def __init__(self, wrapped: Notifier, max_retries: int = 3):
super().__init__(wrapped)
self._max_retries = max_retries
def send(self, recipient: str, message: str) -> None:
last_error = None
for attempt in range(1, self._max_retries + 1):
try:
self._wrapped.send(recipient, message)
return
except Exception as e:
last_error = e
if attempt < self._max_retries:
print(f"Retry {attempt}/{self._max_retries}...")
raise last_error
class RateLimitDecorator(NotifierDecorator):
def __init__(self, wrapped: Notifier, limit: int = 5):
super().__init__(wrapped)
self._limit = limit
self._count = 0
def send(self, recipient: str, message: str) -> None:
if self._count >= self._limit:
raise RateLimitExceeded(
f"Rate limit of {self._limit} notifications exceeded"
)
self._count += 1
self._wrapped.send(recipient, message)
class PrefixDecorator(NotifierDecorator):
def __init__(self, wrapped: Notifier, prefix: str = "[URGENT] "):
super().__init__(wrapped)
self._prefix = prefix
def send(self, recipient: str, message: str) -> None:
self._wrapped.send(recipient, f"{self._prefix}{message}")
# Composition A: RateLimitDecorator is outermost
notifier_a = RateLimitDecorator(LoggingDecorator(EmailNotifier()), limit=2)
# Composition B: LoggingDecorator is outermost
notifier_b = LoggingDecorator(RateLimitDecorator(EmailNotifier(), limit=2))
Output for composition A (rate limit outside):
EMAIL to alice@example.com: message 1
EMAIL to alice@example.com: message 2
Blocked: Rate limit of 2 notifications exceeded
No logging at all — the rate limit check happens before LoggingDecorator ever gets a call.
Output for composition B (logging outside):
SENDING notification to alice@example.com
EMAIL to alice@example.com: message 1
SENT notification to alice@example.com
SENDING notification to alice@example.com
EMAIL to alice@example.com: message 2
SENT notification to alice@example.com
SENDING notification to alice@example.com
Blocked: Rate limit of 2 notifications exceeded
Logging happens for every attempt — including the blocked third one — because LoggingDecorator acts before the rate limit check.
The mental model:
Call flows inward (outermost acts first).
Result flows outward (outermost acts last).
So the outermost decorator has the first word and the last word on every call.
Answers:
notifier = LoggingDecorator(
RateLimitDecorator(
RetryDecorator(
PrefixDecorator(EmailNotifier(), prefix="[ALERT] "),
max_retries=2,
),
limit=10,
)
)
Reading the layers from outside in:
| Layer | Why it sits here |
|---|---|
LoggingDecorator |
Outermost — logs every attempt, including rate-limited ones |
RateLimitDecorator |
Inside logging — rate-limited calls still appear in audit log |
RetryDecorator |
Inside rate limit — each retry attempt counts as one send |
PrefixDecorator |
Close to the metal — the prefix is part of the message content |
EmailNotifier |
The real sender |
If RetryDecorator were outside RateLimitDecorator, each retry would consume an extra slot from the rate limit. Placing retry inside means one logical send uses one rate-limit slot, even if it takes two attempts.
# Risky composition
notifier = RetryDecorator(
RateLimitDecorator(EmailNotifier(), limit=10),
max_retries=3,
)
Now a rate-limited send gets retried three times — each retry hits the rate limit again and raises. The retry logic is useless here and the error message is confusing. Retry should live inside rate limiting, not outside it.
Think about what each layer should see: