Notes and exercises for learning design patterns
This is the solution for Exercise 2: Build a faceted notification builder.
The exercise asks for a NotificationBuilder that exposes smaller builders, or facets, for separate parts of a notification:
| Facet | Responsibility |
|---|---|
recipient |
Who receives the notification. |
content |
The title and body of the notification. |
delivery |
The channel, retries, and delayed sending. |
importance |
The priority of the notification. |
The final usage should support this style:
notification = (
NotificationBuilder()
.recipient
.to("alice@example.com")
.content
.titled("Payment received")
.with_body("Your payment was successfully processed.")
.delivery
.via_email()
.retrying(3)
.send_after(10)
.importance
.high_priority()
.build()
)
Full Python implementation: exercise2_solution.py
The final object stays exactly as required by the exercise:
from dataclasses import dataclass
@dataclass(frozen=True)
class Notification:
recipient: str
title: str
body: str
channel: str
priority: str
retry_count: int
send_after_minutes: int | None
The builder collects the values. The Notification object is created only when .build() is called.
NotificationBuilder is the root builder. It owns the values being collected and exposes the four facets.
class NotificationBuilder:
def __init__(self):
self._recipient_value = None
self._title = None
self._body = ""
self._channel = "email"
self._priority = "normal"
self._retry_count = 0
self._send_after_minutes = None
self._recipient_builder = RecipientFacet(self)
self._content_builder = ContentFacet(self)
self._delivery_builder = DeliveryFacet(self)
self._importance_builder = ImportanceFacet(self)
The internal names use _recipient_builder, _content_builder, and so on because those fields store facet builder objects, not the actual notification values.
The public properties stay clean:
@property
def recipient(self):
return self._recipient_builder
@property
def content(self):
return self._content_builder
@property
def delivery(self):
return self._delivery_builder
@property
def importance(self):
return self._importance_builder
That keeps the fluent API readable:
NotificationBuilder().recipient.to("alice@example.com")
All facets inherit from NotificationFacet.
Its job is to let each facet move to the other facets and call .build().
class NotificationFacet:
def __init__(self, root):
self._root = root
@property
def recipient(self):
return self._root.recipient
@property
def content(self):
return self._root.content
@property
def delivery(self):
return self._root.delivery
@property
def importance(self):
return self._root.importance
def build(self):
return self._root.build()
This is what allows chains like this:
notification = (
NotificationBuilder()
.recipient.to("alice@example.com")
.content.titled("Welcome")
.delivery.via_sms()
.importance.normal_priority()
.build()
)
After .recipient.to(...), the chain is still on a facet object. Because every facet inherits from NotificationFacet, it can move to .content, .delivery, .importance, or .build().
The recipient facet owns the recipient-related construction method.
class RecipientFacet(NotificationFacet):
def to(self, recipient):
self._root._recipient_value = recipient
return self
The method returns self, so you can continue chaining from the same facet:
NotificationBuilder().recipient.to("alice@example.com")
The content facet owns the title and body.
class ContentFacet(NotificationFacet):
def titled(self, title):
self._root._title = title
return self
def with_body(self, body):
self._root._body = body
return self
The default body is an empty string, so .with_body(...) is optional.
The delivery facet owns channel, retry count, and delayed sending.
class DeliveryFacet(NotificationFacet):
def via_email(self):
self._root._channel = "email"
return self
def via_sms(self):
self._root._channel = "sms"
return self
def retrying(self, count):
self._root._retry_count = count
return self
def send_after(self, minutes):
self._root._send_after_minutes = minutes
return self
The defaults are:
| Field | Default |
|---|---|
channel |
"email" |
retry_count |
0 |
send_after_minutes |
None |
The importance facet owns priority.
class ImportanceFacet(NotificationFacet):
def low_priority(self):
self._root._priority = "low"
return self
def normal_priority(self):
self._root._priority = "normal"
return self
def high_priority(self):
self._root._priority = "high"
return self
The default priority is "normal", so this facet is optional unless the caller wants low or high priority.
build()The root builder validates the completed notification.
def build(self):
if not self._recipient_value:
raise ValueError("Recipient is required")
if not self._title:
raise ValueError("Title is required")
if self._channel not in {"email", "sms"}:
raise ValueError("Channel must be either 'email' or 'sms'")
if self._priority not in {"low", "normal", "high"}:
raise ValueError("Priority must be 'low', 'normal', or 'high'")
if self._retry_count < 0:
raise ValueError("Retry count cannot be negative")
if self._send_after_minutes is not None and self._send_after_minutes < 0:
raise ValueError("Send-after minutes cannot be negative")
return Notification(
recipient=self._recipient_value,
title=self._title,
body=self._body,
channel=self._channel,
priority=self._priority,
retry_count=self._retry_count,
send_after_minutes=self._send_after_minutes,
)
Validation belongs in build() because only the root builder has the complete picture.
For example, this fails because there is no recipient:
notification = (
NotificationBuilder()
.content.titled("Hello")
.build()
)
This fails because the retry count is negative:
notification = (
NotificationBuilder()
.recipient.to("alice@example.com")
.content.titled("Hello")
.delivery.retrying(-1)
.build()
)
from dataclasses import dataclass
@dataclass(frozen=True)
class Notification:
recipient: str
title: str
body: str
channel: str
priority: str
retry_count: int
send_after_minutes: int | None
class NotificationBuilder:
def __init__(self):
self._recipient_value = None
self._title = None
self._body = ""
self._channel = "email"
self._priority = "normal"
self._retry_count = 0
self._send_after_minutes = None
self._recipient_builder = RecipientFacet(self)
self._content_builder = ContentFacet(self)
self._delivery_builder = DeliveryFacet(self)
self._importance_builder = ImportanceFacet(self)
@property
def recipient(self):
return self._recipient_builder
@property
def content(self):
return self._content_builder
@property
def delivery(self):
return self._delivery_builder
@property
def importance(self):
return self._importance_builder
def build(self):
if not self._recipient_value:
raise ValueError("Recipient is required")
if not self._title:
raise ValueError("Title is required")
if self._channel not in {"email", "sms"}:
raise ValueError("Channel must be either 'email' or 'sms'")
if self._priority not in {"low", "normal", "high"}:
raise ValueError("Priority must be 'low', 'normal', or 'high'")
if self._retry_count < 0:
raise ValueError("Retry count cannot be negative")
if self._send_after_minutes is not None and self._send_after_minutes < 0:
raise ValueError("Send-after minutes cannot be negative")
return Notification(
recipient=self._recipient_value,
title=self._title,
body=self._body,
channel=self._channel,
priority=self._priority,
retry_count=self._retry_count,
send_after_minutes=self._send_after_minutes,
)
class NotificationFacet:
def __init__(self, root):
self._root = root
@property
def recipient(self):
return self._root.recipient
@property
def content(self):
return self._root.content
@property
def delivery(self):
return self._root.delivery
@property
def importance(self):
return self._root.importance
def build(self):
return self._root.build()
class RecipientFacet(NotificationFacet):
def to(self, recipient):
self._root._recipient_value = recipient
return self
class ContentFacet(NotificationFacet):
def titled(self, title):
self._root._title = title
return self
def with_body(self, body):
self._root._body = body
return self
class DeliveryFacet(NotificationFacet):
def via_email(self):
self._root._channel = "email"
return self
def via_sms(self):
self._root._channel = "sms"
return self
def retrying(self, count):
self._root._retry_count = count
return self
def send_after(self, minutes):
self._root._send_after_minutes = minutes
return self
class ImportanceFacet(NotificationFacet):
def low_priority(self):
self._root._priority = "low"
return self
def normal_priority(self):
self._root._priority = "normal"
return self
def high_priority(self):
self._root._priority = "high"
return self
Valid usage:
notification = (
NotificationBuilder()
.recipient
.to("alice@example.com")
.content
.titled("Payment received")
.with_body("Your payment was successfully processed.")
.delivery
.via_email()
.retrying(3)
.send_after(10)
.importance
.high_priority()
.build()
)
Shorter valid usage using defaults:
notification = (
NotificationBuilder()
.recipient.to("alice@example.com")
.content.titled("Welcome")
.build()
)
This works because these defaults are already set:
| Field | Default |
|---|---|
body |
"" |
channel |
"email" |
priority |
"normal" |
retry_count |
0 |
send_after_minutes |
None |
This solution uses the faceted builder pattern:
NotificationBuilder is the root builder.NotificationFacet is the shared base for all facets.RecipientFacet, ContentFacet, DeliveryFacet, and ImportanceFacet each own one area of construction.self..build() delegates back to the root builder.Notification object is created only after validation passes.