Notes and exercises for learning design patterns
In the main Builder Facets notes, the implementation uses a separate shared base class called something like BuilderFacet.
That structure looks like this:
CustomerAccountBuilder -> root/facade builder
BuilderFacet -> shared base for facets
IdentityBuilder -> identity facet
BillingBuilder -> billing facet
SecurityBuilder -> security facet
NotificationBuilder -> notification facet
There is another compact approach:
CustomerAccountBuilder -> root/facade builder
IdentityBuilder -> inherits from CustomerAccountBuilder
BillingBuilder -> inherits from CustomerAccountBuilder
SecurityBuilder -> inherits from CustomerAccountBuilder
NotificationBuilder -> inherits from CustomerAccountBuilder
In this version, each facet inherits from the root builder. That way, each facet automatically gets access to the root builder’s navigation properties, such as .identity, .billing, .security, .notifications, and .build().
This note explains that approach, shows how it works using the CustomerAccountBuilder example, and then discusses its tradeoffs.
We still want the same public API:
account = (
CustomerAccountBuilder()
.identity
.named("Alice")
.with_email("ALICE@EXAMPLE.COM")
.billing
.with_card_token("tok_123")
.security
.enable_two_factor_auth()
.notifications
.enable_sms_notifications()
.build()
)
The construction still reads in terms of the account’s configuration areas:
identity
billing
security
notifications
The difference is internal implementation.
Instead of this:
class IdentityBuilder(BuilderFacet):
...
we use this:
class IdentityBuilder(CustomerAccountBuilder):
...
That means each facet is also treated as a kind of root builder.
The final object is the same as in the main Builder Facets notes.
from dataclasses import dataclass
@dataclass
class CustomerAccount:
name: str | None = None
email: str | None = None
card_token: str | None = None
two_factor_auth: bool = False
sms_notifications: bool = False
This object only stores the completed data.
The builders are responsible for constructing and validating it.
The root builder owns the CustomerAccount instance and exposes each facet.
class CustomerAccountBuilder:
def __init__(self, account=None):
if account is None:
self._account = CustomerAccount()
else:
self._account = account
@property
def identity(self):
return IdentityBuilder(self._account)
@property
def billing(self):
return BillingBuilder(self._account)
@property
def security(self):
return SecurityBuilder(self._account)
@property
def notifications(self):
return NotificationBuilder(self._account)
def build(self):
if not self._account.name:
raise ValueError("Name is required")
if not self._account.email:
raise ValueError("Email is required")
if self._account.sms_notifications and not self._account.two_factor_auth:
raise ValueError("SMS notifications require two-factor authentication")
return self._account
The constructor has two modes:
CustomerAccountBuilder()
creates a new account.
CustomerAccountBuilder(existing_account)
wraps an account that is already being built.
This two-mode constructor is what lets the root builder and the facet builders share the same account object.
The identity facet inherits from CustomerAccountBuilder.
class IdentityBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def named(self, name):
if not name.strip():
raise ValueError("Name cannot be blank")
self._account.name = name.strip()
return self
def with_email(self, email):
email = email.strip().lower()
if "@" not in email:
raise ValueError("Invalid email address")
self._account.email = email
return self
Because IdentityBuilder inherits from CustomerAccountBuilder, it also has access to:
.billing
.security
.notifications
.build()
So this works:
CustomerAccountBuilder().identity.named("Alice").billing.with_card_token("tok_123")
After .named("Alice"), the chain is still on an IdentityBuilder. Since IdentityBuilder inherits .billing, the chain can jump to the billing facet.
class BillingBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def with_card_token(self, card_token):
if not card_token.startswith("tok_"):
raise ValueError("Card token must start with 'tok_'")
self._account.card_token = card_token
return self
This facet owns billing-specific construction rules.
Since it inherits from CustomerAccountBuilder, it can also move to the other facets:
CustomerAccountBuilder().billing.with_card_token("tok_123").security.enable_two_factor_auth()
class SecurityBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def enable_two_factor_auth(self):
self._account.two_factor_auth = True
return self
class NotificationBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def enable_sms_notifications(self):
self._account.sms_notifications = True
return self
from dataclasses import dataclass
@dataclass
class CustomerAccount:
name: str | None = None
email: str | None = None
card_token: str | None = None
two_factor_auth: bool = False
sms_notifications: bool = False
class CustomerAccountBuilder:
def __init__(self, account=None):
if account is None:
self._account = CustomerAccount()
else:
self._account = account
@property
def identity(self):
return IdentityBuilder(self._account)
@property
def billing(self):
return BillingBuilder(self._account)
@property
def security(self):
return SecurityBuilder(self._account)
@property
def notifications(self):
return NotificationBuilder(self._account)
def build(self):
if not self._account.name:
raise ValueError("Name is required")
if not self._account.email:
raise ValueError("Email is required")
if self._account.sms_notifications and not self._account.two_factor_auth:
raise ValueError("SMS notifications require two-factor authentication")
return self._account
class IdentityBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def named(self, name):
if not name.strip():
raise ValueError("Name cannot be blank")
self._account.name = name.strip()
return self
def with_email(self, email):
email = email.strip().lower()
if "@" not in email:
raise ValueError("Invalid email address")
self._account.email = email
return self
class BillingBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def with_card_token(self, card_token):
if not card_token.startswith("tok_"):
raise ValueError("Card token must start with 'tok_'")
self._account.card_token = card_token
return self
class SecurityBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def enable_two_factor_auth(self):
self._account.two_factor_auth = True
return self
class NotificationBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
def enable_sms_notifications(self):
self._account.sms_notifications = True
return self
account = (
CustomerAccountBuilder()
.identity
.named("Alice")
.with_email("ALICE@EXAMPLE.COM")
.billing
.with_card_token("tok_123")
.security
.enable_two_factor_auth()
.notifications
.enable_sms_notifications()
.build()
)
print(account)
The chain works because every facet is also a CustomerAccountBuilder.
For example:
CustomerAccountBuilder().identity
returns an IdentityBuilder.
Then:
.named("Alice")
returns that same IdentityBuilder.
Because IdentityBuilder inherits from CustomerAccountBuilder, this is available:
.billing
which returns a BillingBuilder wrapping the same account object.
So the movement is:
CustomerAccountBuilder
.identity -> IdentityBuilder(account)
.named(...) -> same IdentityBuilder
.billing -> BillingBuilder(same account)
.with_card_token -> same BillingBuilder
.security -> SecurityBuilder(same account)
.build() -> validates and returns account
The trick is not that the builder objects are the same object. They are often different builder objects. The important part is that they all share the same CustomerAccount instance.
There is no separate BuilderFacet class.
The root builder doubles as the shared base class for all facets.
You can write:
CustomerAccountBuilder().identity.named("Alice").billing.with_card_token("tok_123").build()
without extra navigation methods.
For simple examples, this approach can be easier to write and explain quickly.
The reader only needs to understand:
facets inherit from the root builder
all builders share the same object being built
Since the facet builders inherit from CustomerAccountBuilder, they automatically get:
.identity
.billing
.security
.notifications
.build()
That is what enables cross-facet chaining.
This is the main design downside.
class IdentityBuilder(CustomerAccountBuilder):
...
This says:
An
IdentityBuilderis a kind ofCustomerAccountBuilder.
Conceptually, that is not quite true.
An identity builder is not really the full root builder. It is a facet that configures identity-related fields.
Inheritance is being used here mostly to reuse .billing, .security, .notifications, and .build().
That is convenient, but less precise than saying:
class IdentityBuilder(BuilderFacet):
...
The root builder constructor must support both:
creating a new CustomerAccount
wrapping an existing CustomerAccount
That is why it takes an optional account parameter:
def __init__(self, account=None):
if account is None:
self._account = CustomerAccount()
else:
self._account = account
This is practical, but it means CustomerAccountBuilder is serving two roles:
root builder/facade
base class for facets
A separate facet base keeps those roles apart.
Because facets inherit from the root builder, every facet automatically receives every root method and property.
That can be fine when the root builder is small.
But if the root builder later grows methods like this:
def reset(self):
...
def clone(self):
...
def from_template(self, template):
...
then every facet gets those methods too.
Do you want IdentityBuilder.reset() or BillingBuilder.from_template() to exist?
Maybe not.
A separate BuilderFacet can expose only the behavior facets actually need.
In this approach:
@property
def identity(self):
return IdentityBuilder(self._account)
Every access creates a new builder object.
This is usually not a performance problem because these objects are small.
But it matters if facet builders later hold their own state.
For example:
class IdentityBuilder(CustomerAccountBuilder):
def __init__(self, account):
super().__init__(account)
self._warnings = []
Now this is surprising:
builder.identity
builder.identity
Those are two different IdentityBuilder instances, each with its own _warnings list.
The shared BuilderFacet approach often creates stable facet instances once:
self._identity_builder = IdentityBuilder(self, self._account)
and then returns the same one each time.
This approach says:
IdentityBuilder is a CustomerAccountBuilder
BillingBuilder is a CustomerAccountBuilder
SecurityBuilder is a CustomerAccountBuilder
But the more honest model is:
CustomerAccountBuilder is the root builder
IdentityBuilder is a facet of CustomerAccountBuilder
BillingBuilder is a facet of CustomerAccountBuilder
SecurityBuilder is a facet of CustomerAccountBuilder
This distinction matters more as the codebase grows.
At runtime, Python is fine with this style.
But with type hints, this design can be conceptually awkward because the facet classes inherit the root builder API even though they are not actually root builders.
A separate base like BuilderFacet makes the intent clearer to readers and tools:
class IdentityBuilder(BuilderFacet):
...
That says exactly what the class is.
No.
It is a valid approach, especially for small faceted builders.
It is compact and produces a pleasant fluent API.
It is a good fit when:
the builder is small
there are only a few facets
facets do not hold their own state
the root builder has very little root-only behavior
the team prefers compactness over stricter role separation
It becomes less attractive when:
there are many facets
facet builders hold local state
the root builder has root-only operations
clear type relationships matter
the code is part of a larger library or framework
BuilderFacet approach| Question | Facets inherit root builder | Separate BuilderFacet base |
|---|---|---|
| Boilerplate | Less | More |
| Public fluent API | Good | Good |
| Role clarity | Weaker | Stronger |
| Type hierarchy | Less precise | More precise |
| Facet object stability | Usually new facet per access | Usually stable facet instances |
| Best for | Small examples and simple builders | Larger builders and teaching clarity |
| Main risk | Facets pretend to be root builders | Slightly more code |
Both approaches can produce the same public API.
The difference is mostly about internal structure and how well it scales.
Use the root-builder inheritance approach when you want a compact implementation and the builder is small.
Use a separate BuilderFacet base when you want clearer roles, stable facet objects, and a design that scales better as the builder grows.
In one sentence:
Having facets inherit from the root builder is a compact way to get fluent cross-facet chaining, but it trades some conceptual clarity for convenience.