Module 4: Patterns for Creating Objects
2. Factory: deciding what to build in one place
Description
By the end of this lesson you'll be able to write a Factory in its useful form —much more modest than the catalog suggests— and, more importantly, decide whether one's needed. You'll have its anatomy taken apart into four pieces, you'll know exactly what the code using it gains (something concrete and measurable: it stops knowing which implementations exist), and you'll know the difference between the simple factory function, which solves 90% of real cases, and the catalog's ceremonial variants —Factory Method, Abstract Factory— which in Python rarely earn their place.
This matters because Factory is, along with Strategy, one of the two patterns that show up most in real code, and also one of the two most often misapplied. They get misapplied in two opposite directions. By excess: someone reads "Factory Method" in the book, sees the abstract class hierarchy in the diagram, and builds that whole apparatus to choose between two things —when a six-line function did the same job. And by default: someone writes the six-line function, it works, and never realizes that was already the pattern, so they can't name it in a review or recognize it in someone else's code. Both failures get fixed by the same thing: understanding what problem the pattern solves, instead of what shape the diagram has.
Connection to the module: lesson 1 left you with the problem —the decision of what to build, scattered across four Boletia files— and the three questions that organize the module. This lesson answers the first: what to build. Here we work the pattern in the abstract, with short examples, so the idea stays clean. Lesson 3 takes it to Boletia's complete case, measures before and after in files touched, and shows how Factory combines with the Strategy you built in module 3. Then lesson 4 moves to the second problem —how to build it— and lesson 6 to the third —who builds it. Lesson 7 comes back to this with the uncomfortable question: most of the time, none of the three were needed.
The car-rental counter
You arrive at the airport and go to the car rental agency's counter. You don't say: "I want the white Nissan Versa in slot 14, with the key on hook three." You say: "I reserved a compact." The person at the counter checks their system, walks to the lot, and brings you a car that fulfills what you asked for.
Think about what just happened, because it's exactly the pattern.
You don't know what models the agency has. You don't care either. The only thing that matters to you is that what they hand you has a steering wheel, four wheels, and an ignition —that is, that it fulfills the "car" contract. If the agency switches its entire fleet from Nissan to Chevrolet tomorrow, your experience at the counter is identical: you ask for a compact, you get a compact. Nothing you do has to change.
And on the other side, the counter does know all of that. It knows what models exist, which are available, which corresponds to which category, where the keys are. That knowledge lives concentrated in one place, and that place is the only one that needs updating when a new model comes in.
A Factory is the counter. It concentrates the knowledge of "what exists and how it's built" into a single point, and hands whoever asks something that fulfills a known contract. Whoever asks gets deliberately dumber, and that's a gain, not a loss: the less checkout knows about which payment providers exist, the fewer reasons there are to touch it.
Notice too what the analogy reveals and what the book's diagram hides: the counter doesn't manufacture cars. It has no assembly line. It only chooses and hands over. A software Factory doesn't "manufacture" much either: it almost always just decides which class to instantiate and passes it the right parameters. It's a decision with a return, not machinery.
And one last thing from the analogy, for lesson 7. If the agency had a single car model, the counter would still be useful for the paperwork, but the "choosing" part would be worthless. A Factory that always returns the same thing isn't a Factory: it's a constructor with an extra step.
What a Factory is, in one sentence
A Factory is a function or a class whose only job is to decide what object to build and return it ready to use.
Nothing more. If that sentence seems disappointingly simple, that's because it is. This pattern's complexity in books doesn't come from the idea; it comes from the four or five variants it gets implemented with in languages that lack first-class functions.
Now the anatomy. Every Factory —regardless of language or how much ceremony— has exactly four pieces:
- The contract. What all the possible objects have in common: the same methods, with the same parameters. In Boletia it's
PaymentProvider: all of them knowcharge(order)andrefund(order, amount). Without a contract, no Factory is possible, because whoever receives the object wouldn't know what to do with it. - The implementations. The concrete classes that fulfill that contract:
StripeProvider,MercadoPagoProvider,CashProvider. Each in its own file, each testable separately. These are —and this is the bridge to module 3— the Strategies. - The key. The piece of data used to decide. Almost always a string or an enum coming from the database, the HTTP request, or the configuration. In Boletia it's
order.provider, that free-textstrwe already noted in module 1. - The function that translates key → object. The counter. Receives the key, decides, builds, and returns.
# File: payments/factory.py
# This is the complete Factory. Yes, this is it.
def get_payment_provider(name: str) -> PaymentProvider:
"""Returns the payment provider matching the given name.
This is the ONLY place in the system that knows which providers exist.
If a new one arrives tomorrow, it gets added here and nowhere else.
"""
if name == "stripe":
return StripeProvider(api_key=settings.STRIPE_KEY)
if name == "mercadopago":
return MercadoPagoProvider(token=settings.MP_TOKEN)
if name == "cash":
return CashProvider(store_chain=settings.CASH_STORE_CHAIN)
raise UnknownProviderError(name)
And on the side of whoever uses it:
# File: checkout/checkout.py
def charge_order(order):
# Checkout no longer knows which providers exist. It asks for one and charges.
provider = get_payment_provider(order.provider)
return provider.charge(order)
Stop on those two checkout lines, because that's where the pattern's whole value lives. Before, that function had thirty lines and knew that Stripe charges in integer cents and that cash doesn't charge anything. Now it has two lines and knows nothing. If three new providers arrive tomorrow, this function doesn't change.
An honest observation before continuing: yes, the if/elif still exists. It didn't disappear; it moved. And that's fine, because the problem was never that a conditional existed —the problem was that there were four copies of the same conditional in files that had nothing to do with each other. A conditional in one single place, whose only job is translating a name into an object, is correct, readable code. This is an idea worth taking from the module: many refactors don't eliminate complexity, they relocate it to where it hurts less.
Worked example: from scattered decision to concentrated decision
Let's do the complete refactor on a small case, so the mechanics are clear before applying it to lesson 3's big case. Let's use Boletia's report exporters, simpler than payments: the organizer wants CSV, admin wants PDF, and accounting wants a spreadsheet.
Here's the code today. Two files that know the same thing:
# File: reports/generate.py — BEFORE
# Generates the report requested from the panel.
def generate_report(report_kind, event_id, output_format):
rows = fetch_rows(report_kind, event_id)
# Here we decide the format... and write it along the way.
if output_format == "csv":
exporter = CsvExporter(delimiter=",", encoding="utf-8")
return exporter.write(rows)
elif output_format == "pdf":
exporter = PdfExporter(template=settings.PDF_TEMPLATE, page_size="Letter")
return exporter.write(rows)
elif output_format == "xlsx":
exporter = XlsxExporter(sheet_name="Attendees")
return exporter.write(rows)
else:
raise ValueError(f"Unsupported format: {output_format}")
# File: api/routes.py — BEFORE
# The endpoint that receives the request from the panel.
def get_report(request):
fmt = request.args.get("format", "csv")
# The same list, again, written a different way.
if fmt not in ("csv", "pdf", "xlsx"):
return response(400, {"error": f"Invalid format: {fmt}"})
# And the content-type also depends on the format: third copy of the knowledge.
content_type = {
"csv": "text/csv",
"pdf": "application/pdf",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}[fmt]
body = generate_report(request.args["kind"], request.args["event_id"], fmt)
return response(200, body, content_type=content_type)
Three copies of the "which formats exist" knowledge, across two files. Let's go piece by piece.
Step 1 — Make the contract clear. Before concentrating the decision, every possible object has to look the same from outside. If one gets used with .write(rows) and another with .export(rows, path), no Factory is worth anything: whoever uses it would have to ask what they got, and we'd be back where we started.
# File: reports/exporter.py
from typing import Protocol
class ReportExporter(Protocol):
"""The common contract: every exporter knows how to write rows and state its content-type.
We use Protocol (structural typing) instead of an abstract base class because
in Python you don't need to inherit to fulfill a contract: having the methods
is enough. Less ceremony, and the type checker still warns you.
"""
def write(self, rows: list[dict]) -> bytes: ...
@property
def content_type(self) -> str: ...
Notice what we just did with content_type: it was knowledge sitting loose in routes.py, in a dictionary that had to be manually kept in sync with the format list. We moved it where it belongs —each exporter knows its own. When you concentrate a decision, other bits of scattered knowledge show up wanting to move with it. It's a sign you're on the right track.
Step 2 — Write the factory. One file, one function, all the knowledge together:
# File: reports/factory.py — AFTER
class UnknownFormatError(ValueError):
"""Its own error, so whoever calls it can tell it apart from any other ValueError."""
def __init__(self, fmt: str):
self.format = fmt
super().__init__(
f"Unsupported report format: {fmt!r}. "
f"Available: {', '.join(available_formats())}"
)
# The registry: the single source of truth about which formats exist.
# We store the CLASS, not an instance, because each exporter is built
# with its own parameters and we don't want to create all of them if only one is used.
_EXPORTERS: dict[str, type] = {
"csv": CsvExporter,
"pdf": PdfExporter,
"xlsx": XlsxExporter,
}
def available_formats() -> list[str]:
"""The available formats. Now they can be ASKED about, not memorized."""
return sorted(_EXPORTERS)
def get_exporter(fmt: str) -> ReportExporter:
"""Returns the exporter for the requested format, already built and ready."""
try:
exporter_class = _EXPORTERS[fmt]
except KeyError:
raise UnknownFormatError(fmt) from None
return exporter_class()
Step 3 — Replace each copy with a call. The generator ends up like this:
# File: reports/generate.py — AFTER
def generate_report(report_kind, event_id, output_format):
rows = fetch_rows(report_kind, event_id)
exporter = get_exporter(output_format) # no longer knows which formats exist
return exporter.write(rows)
And the endpoint:
# File: api/routes.py — AFTER
def get_report(request):
fmt = request.args.get("format", "csv")
try:
exporter = get_exporter(fmt)
except UnknownFormatError as err:
# The error message already comes assembled, with the real format list.
return response(400, {"error": str(err)})
rows = fetch_rows(request.args["kind"], request.args["event_id"])
return response(200, exporter.write(rows), content_type=exporter.content_type)
What to expect from this refactor. Let's go through the concrete part first, then the interesting one.
The concrete part: adding a new format —say JSON, because the organizer wants to consume the list from their own system— is now creating reports/json_exporter.py and adding one line to _EXPORTERS. Two files, one new. Before it was three places across two existing files, plus the content-type dictionary you had to remember. And if you forget the registration, the system fails immediately with a clear message —UnknownFormatError— instead of failing silently.
What deserves close attention: the endpoint stopped having its validation list. Before there was an if fmt not in ("csv", "pdf", "xlsx") that had to be manually kept in sync with the other list, and that manual sync between two lists is exactly the kind of thing that goes out of sync. Now validation is a side effect of asking for the object: if it exists, you get it; if not, you get an error that already carries the real list of options. A single source of truth, queried instead of duplicated.
And now the interesting part, which almost nobody points out. Compare the two excepts. Before, the error message was hand-written in each place and said different things —"Unsupported format" in one file, "Invalid format" in the other. Afterward, there's a dedicated error type whose message assembles itself from the registry. A system's error messages are a fairly faithful mirror of how many copies of knowledge it has. If the same problem gets reported with three different texts in your code, there are three copies. It's a free diagnostic you can run on any codebase: search for similar messages.
An honest nuance to close with. Notice that in generate.py you now have to call fetch_rows and get_exporter separately, and in routes.py too. That small rearrangement is real: the refactor moved a responsibility, and that always shuffles the neighbors a little. It isn't free, it's cheap. The difference matters: in module 2 you learned abstractions have a cost, and this one costs one more file and one more reading jump. What it buys —adding formats without touching existing code and without being able to forget a place— is worth more than that when formats are genuinely being added. If Boletia had only ever exported CSV, this refactor would have been a net loss.
Dictionary or conditional: which registry fits
You saw two ways of writing the counter: the if chain and the dictionary. Both are Factory; neither is more "correct." You choose based on judgment.
The if chain works when building each option is different and detailed. Look at the payments one again: StripeProvider needs an API key, MercadoPagoProvider a token, CashProvider the store chain. Every branch builds its own way. Cramming that into a dictionary would force you to store anonymous functions or invent an artificial common signature, and it would end up less readable than the if. The if also allows conditions that aren't simple equality —a provider only available in certain countries, a test mode.
def get_payment_provider(name: str) -> PaymentProvider:
if name == "stripe":
return StripeProvider(api_key=settings.STRIPE_KEY)
if name == "mercadopago":
return MercadoPagoProvider(token=settings.MP_TOKEN)
if name == "cash":
# Cash only makes sense in Mexico, where the store network is.
if settings.COUNTRY != "MX":
raise ProviderNotAvailableError(name, settings.COUNTRY)
return CashProvider(store_chain=settings.CASH_STORE_CHAIN)
raise UnknownProviderError(name)
The dictionary works when every option is built the same way, like the exporters. And it brings two advantages the if doesn't have: you can ask what options exist —that was available_formats()— and you can iterate over it. That ability to ask is more useful than it looks: it's used to build the interface's dropdown menu, to validate on input, for the error message, and to write a test verifying every registered option fulfills the contract.
# A test only possible with the dictionary registry:
# verifies EVERYTHING registered fulfills the contract, without listing them by hand.
def test_every_registered_exporter_honors_the_contract():
for fmt in available_formats():
exporter = get_exporter(fmt)
assert hasattr(exporter, "write")
assert isinstance(exporter.content_type, str)
That test is a small luxury: when someone adds the JSON exporter and forgets to implement content_type, the test fails without anyone having written a new test.
A warning about the dictionary, because it's a real trip hazard. If you store instances instead of classes, you're building all of them when the module gets imported, even though the system only uses one:
# ⚠️ Watch out for this: builds all three exporters when the file gets imported.
_EXPORTERS = {
"csv": CsvExporter(),
"pdf": PdfExporter(template=load_template()), # ← reads a file from disk on import
"xlsx": XlsxExporter(),
}
With cheap exporters, nothing happens. With payment providers that open a connection or read a credential from a secrets service, this turns into a slow startup and a confusing error —fails on import, not on use. Store the class or a function that builds it, and let the factory instantiate only what's requested. It's the difference between a lot full of parked cars and having every car's engine running all day.
The simple form versus the catalog's variants
Now the part worth saying out loud, because the confusion it produces is responsible for a lot of over-engineered code.
When you search "Factory pattern" you find at least three different things with similar names:
Factory function (or simple factory). What you just wrote: a function that, given a piece of data, returns the correct object. Strictly speaking, this isn't in the 1994 book's original catalog —and that fact explains everything else. It isn't there because in the languages of that era you couldn't write it this simply. It's, by a huge margin, the form you're going to use most, and it's almost always the correct one.
Factory Method. The catalog's variant. The idea: a base class defines a method that creates something, and subclasses decide what that method creates. The decision isn't made with an if but by choosing which subclass you instantiate.
# Factory Method — the catalog's variant, with inheritance.
class ReportJob:
"""A report job: fetches the rows and exports them.
The skeleton is here; WHICH exporter is used is decided by each subclass.
"""
def run(self, event_id):
rows = fetch_rows(event_id)
exporter = self.create_exporter() # ← the "factory method"
return exporter.write(rows)
def create_exporter(self) -> ReportExporter:
raise NotImplementedError
class CsvReportJob(ReportJob):
def create_exporter(self):
return CsvExporter()
class PdfReportJob(ReportJob):
def create_exporter(self):
return PdfExporter(template=settings.PDF_TEMPLATE)
When does this earn its place? When you already have a class hierarchy for other reasons and creation is one of the things that varies between them. If you notice, in the example, run() is identical across all subclasses and the only thing that changes is what gets built, then the hierarchy exists only to choose the exporter, and that's exactly the case where a factory function does the same job with two fewer classes. Also notice this is first cousin to the Template Method you saw in module 3 —same skeleton, one different step; the difference is that here the step that varies is a construction.
Abstract Factory. The most ceremonial one. It solves a specific, uncommon problem: when you have to build families of objects that need to be coherent with each other. The classic case is graphical interfaces —if you're in dark mode, you want the dark button and the dark menu and the dark text field, and mixing them would be a visible mistake.
Does that problem exist in Boletia? With some effort, yes: in the test environment you want the fake payment provider and the fake notification channel and the exporter that writes to memory; you never want the fake provider paired with the real email sender, because that sends real emails from a test. But look how that gets solved in Python without Abstract Factory:
# The "coherent set of dependencies" — no pattern, just a dataclass.
@dataclass
class Services:
"""The external pieces the application needs, grouped.
In production they get assembled with the real ones; in tests, the fake ones.
Nothing guarantees coherence by magic: what guarantees it is building
them together in one place and never mixing them.
"""
payments: PaymentProvider
notifications: NotificationChannel
exporter: ReportExporter
def build_production_services() -> Services:
return Services(
payments=get_payment_provider(settings.DEFAULT_PROVIDER),
notifications=EmailChannel(smtp_host=settings.SMTP_HOST),
exporter=get_exporter("pdf"),
)
def build_test_services() -> Services:
return Services(
payments=FakePaymentProvider(),
notifications=RecordingChannel(), # stores alerts in a list
exporter=InMemoryExporter(),
)
Two functions and a dataclass. That's an Abstract Factory, without the diagram's four abstract classes. The difference between this and the book's pattern isn't conceptual: it's that Python lets you write the idea directly, while a language without first-class functions needs to wrap it in classes just to pass it around.
The practical recommendation, stated plainly: always start with the factory function. It's the one that solves the real problem —the scattered decision— with the minimum number of pieces. If you ever genuinely need more ceremony, the path from the simple function to a bigger variant is easy; the path back, from a class hierarchy to a function, almost never gets walked because nobody ever finds the time. It's module 2's argument, wearing a different outfit: it's cheaper to add structure later than to remove it later.
What exactly whoever uses it gains
It's worth being precise about the benefit, because "decouples" is a word said a lot and defined little.
What the code using a Factory gains is this: it stops knowing which implementations exist. Nothing more, and it's enough. Let's see it as a list of things checkout used to know and no longer does:
- That a class called
StripeProviderexists. Now it can't even name it. - That it's built with
api_key, and that key comes fromsettings.STRIPE_KEY. - That there are exactly three providers, and which ones.
- That cash doesn't charge but generates a reference.
Each of those four things was a reason someone might have needed to open and modify the checkout file. Four fewer reasons to touch the system's heart. In vocabulary you already have from the foundations: checkout's coupling toward the payments module went down, and its cohesion went up along the way —now checkout only talks about orchestrating a purchase, not third-party credentials.
There's a second benefit, less cited and very practical: the single point becomes a place to put things. When all creation goes through one function, that function is the natural place to add an audit log, a metric, a startup configuration validation, or a retry wrapper —which is what module 5's Decorator will do. Without that single point, any of those additions goes back to being a modification across four files.
And a third, the most appreciated day to day: you can substitute what the factory returns in tests. Though that's done much better with dependency injection —lesson 6— and not by patching the factory underneath, which is a fragile trick we'll discuss there.
Now the cost, because this guide never presents a pattern without one. A Factory costs:
- One more file and one more reading jump. Whoever reads
get_payment_provider(order.provider)and wants to know what actually happens has to open another file. With three providers, trivial; with twenty nested factories, module 2's indirection hell. - A bit of opacity in error tracing. When something fails inside
StripeProvider, the trace goes through the factory, and whoever doesn't know the system takes a moment longer to understand where that object came from. - The temptation to grow. Factories have a well-documented tendency to accumulate logic that isn't theirs: "while we're here, let's validate the order," "while we're building the provider, let's log the attempt." A factory that does more than decide and build stopped being a factory and started being a tiny God object.
Common mistakes
The single-implementation factory (judgment). What happens: someone writes def get_notifier(): return EmailChannel(...). It receives no data, decides nothing, always returns the same thing. It's a constructor with a longer name and one more file. Worse: because it's called "factory," the next person to show up is going to assume there are several implementations and waste time looking for them. Why it happens: it's pure speculative abstraction —"someday there'll be another channel"— the exact mistake module 2 dismantles in its lesson 4. How to spot it: the test takes one second. Does your factory have just one branch, or none? Does it receive a piece of data to decide with? If it decides nothing, it isn't a factory. How to fix it: call the constructor directly and be done with it. When the second implementation shows up —genuinely, not imagined— extracting the factory is going to take ten minutes, and by then you'll know what the real axis of variation is.
The factory returning things that don't fulfill the same contract (implementation). What happens: the factory returns StripeProvider and MercadoPagoProvider, which have charge(), but also returns CashProvider, which generates a reference instead of charging and has no refund(). So whoever uses it goes back to asking: if isinstance(provider, CashProvider): .... The decision came back, disguised. Why it happens: almost always because the contract was designed looking at two implementations and the third didn't fit, and instead of revisiting the contract, the odd case got forced in. How to spot it: search for isinstance or type checks after a factory call. It's the most reliable smell for this mistake. How to fix it: revisit the contract until all three implementations genuinely fulfill it. In Boletia, that means charge() doesn't return "the charge" but a PaymentResult that can be succeeded or pending with a reference; and that refund() exists on all three, even if for cash what it does is schedule a manual refund. If an implementation truly can't fulfill the contract, maybe it doesn't belong to that family.
Confusing the factory with the place where which one gets used is decided (conceptual). What happens: someone writes the factory, calls it from checkout with order.provider, and calls the job done. But the choice of which provider goes into order.provider keeps getting made in three different places —the web form, the mobile app, the automatic renewals process— each with its own "which one's best" logic. The factory concentrated how it gets built, not how it gets chosen. Why it happens: they're two similar decisions, and in textbook examples they coincide, because the key comes from a single place. In real systems they almost never coincide. How to spot it: ask yourself who decides the key's value. If the answer is several places with their own rules, you have a second problem the factory doesn't touch. How to fix it: separate the two questions explicitly. A choose_provider(customer, order, country) function that decides which one's best, and the factory that builds the one chosen. And be honest about whether that second function is even needed: sometimes the choice is simply "whatever the user tapped on screen," and there's nothing to concentrate there.
Exercises
Exercise 1 — Decide whether these three cases call for a Factory. For each one, answer yes or no and justify with module 2's judgment —the rule of three, the cost of indirection, how many implementations exist today.
(a) Boletia has three notification channels (EmailChannel, SmsChannel, PushChannel) and notifier.py builds all three with an if based on what the customer has available. It's the only place in the system where they get built.
(b) Boletia stores files —the tickets' PDFs— and today writes them to local disk. Next quarter's plan is moving them to cloud storage. There's a single place that writes files.
(c) Boletia calculates taxes, and today there are two cases: Mexico (16% VAT) and "no tax" for free events. Used in the total calculation and in generating the invoice.
See solution
(a) No, not yet. There are three implementations —the rule of three is met— but the decision is in one single place, and that place is exactly the one that should be making it. A factory here would add a file and a jump without removing any duplication, because there's no duplication to remove. Notice the distinction, which is the exercise's most important part: the rule of three talks about how many implementations there are; the problem Factory solves is how many places decide. Three implementations and a single decision place don't call for Factory. (Note: in lesson 8's project you're going to look at this case again, and there you'll discover notifier.py really isn't the only place. The answer changes with the data.)
(b) No. There's one implementation. The second one's planned, which is different from existing. This is the literal YAGNI case and module 2's premature abstraction: if you build the factory today with a single branch, you're pinning the axis of variation with today's information, and it's most likely that when cloud storage arrives you'll discover the real difference wasn't "where it's stored" but "how signed URLs get generated" or "what happens with large files." Leave the direct code and extract the abstraction the day the second implementation exists, with complete information. That day it's going to cost you twenty minutes.
(c) Probably not, and here's the interesting part why. There are two implementations, not three —rule of three not met— and on top of that, "no tax" isn't really another implementation: it's the same one with a zero rate. What genuinely emerges isn't a hierarchy but a piece of data: tax_rate. Before applying any creational pattern, check whether what varies is behavior or a value. If it's a value, the answer is a configuration table, not a family of classes. This mistake —turning what was a table into classes— is one of the most common and most expensive, and you're going to see it again in module 7.
Why it works: all three cases look like Factory candidates at first glance, and none of them is. That's the point. Module 2 wasn't a parenthesis: it's the filter applied before every pattern from here on.
Exercise 2 — Write the notification channel factory. Boletia needs a NotificationChannel chosen by its name ("email", "sms", "push"). Each channel is built differently: the email one needs the SMTP server, the SMS one needs an API key and a sender, the push one needs the app's credentials. Write the factory, decide whether to use a conditional or a dictionary, and justify your choice. Include handling for an unknown name.
See solution
# File: notifications/factory.py
class UnknownChannelError(ValueError):
def __init__(self, name: str):
self.name = name
super().__init__(
f"Unknown notification channel: {name!r}. "
f"Available: {', '.join(available_channels())}"
)
def available_channels() -> list[str]:
return ["email", "push", "sms"]
def get_notification_channel(name: str) -> NotificationChannel:
"""Builds the requested channel. Only place that knows which channels exist."""
if name == "email":
return EmailChannel(
smtp_host=settings.SMTP_HOST,
smtp_user=settings.SMTP_USER,
smtp_password=settings.SMTP_PASSWORD,
)
if name == "sms":
# The sender is a regulated piece of data: in Mexico it has to be an
# alphanumeric ID registered with the carrier, so it comes from config, not fixed.
return SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER)
if name == "push":
return PushChannel(app_credentials=settings.PUSH_CREDENTIALS)
raise UnknownChannelError(name)
Why conditional and not dictionary: because each channel is built with different parameters. Fitting them into a dictionary would require storing argument-less functions —{"email": lambda: EmailChannel(...)}— and that gains nothing in readability; it just swaps a readable if for denser syntax. The dictionary pays off when options are built the same way, and that isn't the case here.
The detail that separates a good solution from an average one: available_channels() is hand-written, and that's a second copy of the knowledge —exactly what we came to eliminate. If someone adds a channel to the if and forgets the list, the error message lies. There are two honest exits. The first is accepting the dictionary after all, with the construction wrapped:
_CHANNELS = {
"email": lambda: EmailChannel(smtp_host=settings.SMTP_HOST, ...),
"sms": lambda: SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER),
"push": lambda: PushChannel(app_credentials=settings.PUSH_CREDENTIALS),
}
def available_channels() -> list[str]:
return sorted(_CHANNELS) # now yes, a single source of truth
def get_notification_channel(name: str) -> NotificationChannel:
try:
build = _CHANNELS[name]
except KeyError:
raise UnknownChannelError(name) from None
return build()
The second is leaving the if and writing a test that walks available_channels() and verifies each one builds without exploding. Both are reasonable; the first avoids the problem, the second detects it. What isn't reasonable is leaving both lists with nothing keeping them in sync.
If you got to the if with the hand-written list and didn't see the problem, no harm done —it's exactly the trip hazard this exercise was set up to trigger. What matters is the signal: every time you write a factory, ask yourself whether some other list of the same thing is left lying around somewhere.
Exercise 3 — Find the disguised factory. This Boletia code doesn't mention the word "factory" anywhere. Is there one? Where? Is it well solved, or is something missing?
# File: pricing/calculator.py
def price_for(ticket, purchased_at):
rule = RULES_BY_KIND.get(ticket.kind, GeneralRule())
return rule.apply(ticket.base_price, purchased_at)
RULES_BY_KIND = {
"general": GeneralRule(),
"vip": VipRule(surcharge=0.30),
"early_bird": EarlyBirdRule(cutoff=date(2026, 3, 1), discount=0.20),
"courtesy": CourtesyRule(),
}
See solution
Yes, there's a factory, and it's in the RULES_BY_KIND.get(...) line. It has the four pieces: the contract (PricingRule, all of them know apply), the implementations (module 3's four rules), the key (ticket.kind), and the key → object translation (the .get on the dictionary). That it isn't called get_pricing_rule doesn't change what it is. This is what module 1's lesson 5 taught you to do: recognize the pattern in code nobody labeled.
Now the three things it's missing, in order of severity:
First and most serious: the .get with a default value hides errors. If someone tomorrow inserts a ticket into the database with kind = "vp" —a typo— this code doesn't fail: it silently applies the general rule and charges the wrong price. A VIP ticket sold at general price is a money problem, not a code one, and nobody finds out until someone reviews the numbers. Compare it against the lesson's factory: raise UnknownProviderError(name). A factory should fail when asked for something it doesn't know; the silent default is convenient today and expensive later. If the default is a deliberate business decision —"any unknown type gets charged as general"— it should at least get logged, and be commented as a decision, not an oversight.
Second: it stores instances, not classes or constructors. All four rules get built on module import. With these rules there's no drama because they're cheap, stateless objects. But there's a hidden trap in the third line: EarlyBirdRule(cutoff=date(2026, 3, 1)) fixes the cutoff date in code, at import time. That means every event with a different cutoff date doesn't fit this structure, and changing the date requires a deployment. The shared, once-built object looks like efficiency; here it's a business limitation disguised as a technical detail. If the rules had mutable state, on top of that, every order in the system would be sharing the same object —with the consequences you're going to see in lesson 5.
Third, the mildest: you can't properly ask what types exist. You can do RULES_BY_KIND.keys(), sure, but since the dictionary is public and mutable, any part of the system can add or remove entries from it. A private _RULES_BY_KIND with an available_kinds() function is more honest about who's in charge.
Why it works: 90% of the factories you're going to find in real code are like this, disguised and half-done. You don't need to rewrite them; you need to see them and know what they're missing. With those two skills you can write a two-line review comment worth more than a full day's refactor: "this is a factory and I like it; what worries me is the silent default —a typo'd kind gets charged as general and nobody finds out. Should we make it fail?"
Summary and next step
In this lesson you defined Factory in the only form you're going to need most of the time: a function that, given a piece of data, returns the correct object. You took its anatomy apart into four pieces —the contract, the implementations, the key, and the translating function— and saw that without a contract there's no possible Factory, because whoever receives the object wouldn't know what to do with it.
You did the complete refactor on Boletia's report exporters and measured the result: three copies of the knowledge across two files, turned into a single registry that can also be queried. You saw the if didn't disappear —it moved to where it hurts less— and that when you concentrate a decision, other loose bits of knowledge —the content_type, the error messages, the validation list— want to move with it. You learned to choose between a conditional and a dictionary based on how the options get built, and to store classes or constructors instead of instances.
And you saw the difference between the simple factory function —not even in the original catalog, because in 1994 you couldn't write it this simply— and the ceremonial variants: Factory Method, which earns its place only if a hierarchy already exists for other reasons, and Abstract Factory, which in Python gets solved with a dataclass and two functions. The pocket recommendation: always start with the function; adding structure later is cheap, removing it later almost never happens.
Before moving on you should be able to: write a factory function with unknown-key handling; explain in one sentence what the code using it gains; recognize a disguised factory in unlabeled code; and —above all— say why three implementations at a single decision point don't call for a Factory.
Lesson 3 takes this to the big case. We're going to take the four Boletia files you saw in lesson 1, centralize the PaymentProvider choice, and precisely measure what changes when PayPal comes in. And we're going to see the part books artificially separate: how this Factory combines with the Strategy you built in module 3, because in real code patterns never come one at a time.
Resources
- Refactoring Guru — Factory Method and Abstract Factory — the catalog's two variants with their diagrams. Read them knowing they present the ceremonial form as the normal case.
- python-patterns.guide — The Factory Method Pattern — Brandon Rhodes explains why this variant is almost never needed in Python and what's used instead. It's the previous reference's critical complement.
- Refactoring — Replace Conditional with Polymorphism — the refactor that turns the conditional's branches into implementations. It's the step before the Factory has something to manufacture.
- PEP 544 — Protocols: Structural subtyping — the proposal that introduced
Protocolinto Python. It's the modern way to declare a contract without forcing inheritance, and the one we used in this lesson.