Module 4: Patterns for Creating Objects
7. When a plain constructor or function is enough
Description
By the end of this lesson you'll have the antidote to what you just learned. After five lessons teaching Factory, Builder, and dependency injection, it's time to say out loud what module 2 had already installed: the vast majority of a system's objects need none of this. They get built with a constructor, get used, and that's it. You'll walk away with the three concrete signals that a creational pattern genuinely is needed, with the three false signals that fool everyone, and with a way of measuring ceremony's cost you can use in a code review without it sounding like opinion.
This matters for a reason you already know by name, and one that now hits close to home. In module 1 we talked about new-hammer syndrome: whoever just learned a pattern sees the problem that pattern solves everywhere. You're at that exact moment. You just spent five lessons watching a Factory tidy up a four-file mess, a Builder tame an eleven-parameter constructor, and injection make the improbable testable. All three worked. And that's exactly the mental condition over-engineered code gets written in: not out of carelessness, but out of well-founded enthusiasm.
This lesson isn't here to tell you the previous ones were wrong. It's here to put a filter in front of them. And the filter is more useful than the patterns, because you can look up patterns when you need them, and you can't look up judgment.
Connection to the module: this is the vaccine, and it deliberately comes after the three techniques and before the project. Lessons 2 and 3 gave you Factory, lesson 4 Builder, lesson 6 injection; this one puts all three under the same question: does this earn its place? It's module 2's direct application —every abstraction has a cost, the rule of three, YAGNI— to the specific territory of object creation. And it sets up lesson 8's project, which is explicitly going to ask you to organize scattered creation with the minimal pattern and turn in, in writing, what you decided not to abstract. Without this lesson, that project gets solved by adding three patterns. With it, it gets solved by thinking.
The power screwdriver for hanging a picture
Someone gives you a tool set. It comes with a hammer drill, a power screwdriver with eighteen bits, a laser level, and a box of expansion screws. It's a good gift and the tools are good.
The next day you want to hang a small picture on a drywall. The correct way to do that is with a small nail and three hammer taps: thirty seconds, and if you get the height wrong, you pull it out and start over.
But you have the new set. So you grab the drill, look for the bit, mark with the laser level, drill, insert the anchor, screw it in. Twenty minutes. The picture ends up hung —just as well as with the nail— and now there's an eight-millimeter hole with a plastic anchor inside. If you want to move the picture tomorrow, that hole stays there forever.
Notice the three things it cost, because they're exactly the three things an unnecessary pattern costs:
It cost time today. Twenty minutes against thirty seconds.
It cost reversibility. The nail comes out and leaves nothing behind. The anchor stays. In code: a function gets deleted in a minute; a class hierarchy that already has three users, nobody touches.
It cost a false expectation. Whoever sees that anchor is going to assume something heavy used to hang there and is going to treat it with respect. In code: whoever sees a NotifierFactory is going to assume several notifiers exist and is going to go looking for them.
That third cost is the most underrated and the most expensive in the long run. An abstraction is a promise. When you write a factory, you're communicating "there are several implementations and this chooses among them." If there's only one, you lied without meaning to, and the next person will waste half an hour looking for what you promised.
What gets built and that's it
Let's look at Boletia honestly. How many of its objects need a creational pattern?
# These get built and done. Nothing to decide, nothing to assemble in
# steps, nothing to substitute in tests.
ticket = Ticket(event_id=9, kind="vip", base_price=1200.0, seat="A-14")
money = Money(amount=1740.0, currency="MXN")
customer = Customer(id=142, name="Ana Ruiz", email="ana@example.com")
result = PaymentResult(status="succeeded", external_id="ch_1a2b3c")
fee = ProviderFee(percentage=0.036, fixed=3.0)
billing = BillingInfo(name="Ana Ruiz", tax_id="RUAN850312AB1", address="Reforma 222")
date_range = DateRange(start=date(2026, 3, 1), end=date(2026, 3, 31))
Seven objects. None of them needs anything. And they aren't minor cases: Ticket and Order are the heart of Boletia's domain. The heart of a system almost always gets built with ordinary constructors.
Why? Because creational patterns solve problems that show up at boundaries: where there are several interchangeable implementations, where you talk to something external, where configuration comes in. The center of a system —the business data, the pure rules— rarely has those problems.
That's the first observation I want to leave you with, because it reorganizes the map: creational patterns live at the system's edge, not at its core. If you're applying one at the domain's heart, double-check.
The three signals it genuinely is needed
Now the filter. A creational pattern earns its place when at least one of these three things shows up. Not two of three, not "sort of": one, clearly.
Signal 1 — The same decision gets made in more than one place
This is Factory's signal, and it's the most objective of the three because it can be counted.
It isn't about a conditional existing. It's about the same conditional —the same knowledge about which options exist— showing up in several files. In Boletia there were four places, written four different ways.
The concrete test, the one you can run on any code:
How many files do I have to open to add a new option?
One: no problem to solve. Three or more: you have the problem, and Factory solves it.
And pay attention to the nuance you already saw in lesson 2's exercise 1, because it's where people get it wrong most: three implementations at a single decision point don't call for Factory. Module 2's rule of three talks about how many implementations there are; this signal talks about how many places decide. They're two different numbers, and you have to look at the second one.
Signal 2 — Construction is genuinely complex
This is Builder's signal, and "complex" has a precise definition, not a field-count threshold.
It isn't "has a lot of parameters" —a dataclass with keyword arguments solves that, as you saw in lesson 4. It's one of these three:
- The data arrives at different times. Boletia's cart, the event draft saved half-done.
- Adding each piece requires work.
add_ticketchecks the pricing rule, calculates, and accumulates. It isn't an assignment. - There are rules only checkable with the complete object. "A courtesy-only order can't accept a coupon" needs to see the tickets and the discount at the same time.
The concrete test:
Do my steps do something, or just store?
If they're all self._x = x, there's no construction complexity: there are a lot of fields. And a lot of fields is solved by the language.
Signal 3 — You need to substitute the piece
This is dependency injection's signal, and it's the most frequent of the three.
"Substitute" means two concrete things: in tests, being able to pass a double; in production, being able to pass a different implementation depending on context —another provider, another country, another environment.
The concrete test, and it's a single question:
Am I ever going to pass something different to it?
If the honest answer is "never," that parameter is noise in the signature. If it's "yes, in tests," that's it: inject it.
And the shortcut that sums up this signal: you inject what crosses a boundary —network, disk, database, clock, chance, external processes— and what has more than one real implementation. What's pure and what's cross-cutting, no.
The three false signals
Now the ones that fool you. These three feel like reasons and aren't.
False 1 — "There's an if"
A conditional isn't a smell. A conditional is how a decision gets written, and programs make decisions.
# This is PERFECT. Don't touch it.
def format_seat(ticket) -> str:
if ticket.seat is None:
return "General admission"
return f"Row {ticket.seat[0]}, seat {ticket.seat[1:]}"
Two branches, not going to grow —a ticket has a seat or it doesn't, there's no possible third case— lives in one place. Turning this into a SeatFormatter hierarchy with two implementations and a factory would be three files to replace four lines.
It's the same vaccine module 3 already gave you in its lesson 7 —not every conditional is a hidden Strategy— applied to creation. What matters isn't that there's an if: it's whether the if grows and whether it's repeated.
False 2 — "Someday we're going to need another one"
This is YAGNI, and it's the false signal that produces the most expensive code.
The problem isn't that the prediction is wrong —sometimes it's right. The problem is that abstracting today forces you to choose the axis of variation with today's information, which is the worst information you're ever going to have about that problem.
A real example from Boletia. Two years ago someone thought: "someday we're going to store PDFs in the cloud." They wrote a Storage interface with save(path, data) and a local implementation. A year later cloud storage arrived, and it turned out the important difference wasn't where it's stored: it was that the cloud returns signed URLs with an expiration, that large files need multipart upload, and that deleting costs money. The interface written accounted for none of that, so it had to be redone —and now there were five places using it. The premature abstraction didn't save work: it duplicated it, and made the second job riskier along the way.
Module 2's rule applied here: wait for the second real case, and preferably the third. By then you'll know what the real axis is.
False 3 — "This looks more professional"
Nobody says this one out loud, and it's the most common.
A file with an interface, two implementations, and a factory looks like big-company code. A file with a twelve-line function looks like a script. And there's real pressure —in code reviews, in interviews, in your own head— toward the first.
Worth saying bluntly: on a mature team, simple code that solves the problem is valued more than structured code that solves the same problem with three files. The skill that stands out isn't applying patterns: it's knowing when not to. Anyone can add a layer; removing one requires understanding the problem.
And there's a version of this false signal that's harder to spot because it comes disguised as humility: "I do it this way because that's how the examples do it." Pattern examples are written to show the pattern. Their context is "here's a case where this applies," not "this is how everything's written."
Worked example: five decisions on the same code
Let's apply the filter to five Boletia corners. In each one, the trained reflex says "pattern," and the correct answer isn't always that.
Case 1 — The coupon validator.
def validate_coupon(code: str, subtotal: float) -> Coupon:
coupon = find_coupon(code)
if coupon is None:
raise UnknownCouponError(code)
if coupon.expires_at < date.today():
raise ExpiredCouponError(code)
if subtotal < coupon.minimum_purchase:
raise CouponNotApplicableError(code, coupon.minimum_purchase)
return coupon
Signal 1 (repeated decision?) No, validated in one place.
Signal 2 (complex construction?) Builds nothing, validates.
Signal 3 (needs substituting?) Two candidates: find_coupon touches the database and date.today() is the clock.
Verdict: inject two things, nothing more.
def validate_coupon(code, subtotal, coupons: CouponRepository, today: date) -> Coupon:
...
Notice today: date instead of a callable clock. It's lesson 6's exercise 3 rule: pass the value, not the source of the value. This function doesn't need a clock; it needs a date.
Case 2 — Barcode generation.
def barcode_for(ticket) -> str:
prefix = "BOL"
checksum = sum(ord(c) for c in f"{ticket.event_id}{ticket.id}") % 97
return f"{prefix}-{ticket.event_id:05d}-{ticket.id:07d}-{checksum:02d}"
All three signals: no, no, and no. It's a pure function, in one place, no dependencies.
Verdict: it stays exactly as it is. And this isn't a filler case: it's 70% of the code in any system. Functions that do one thing with what they receive. Most of your work is going to be writing code like this, and that's fine.
Case 3 — Fee calculation for the report.
# scripts/export_provider_fees.py
def total_fees(orders):
total = 0.0
for order in orders:
if order.provider == "stripe":
total += order.total * 0.036 + 3.0
elif order.provider == "mercadopago":
total += order.total * 0.041
elif order.provider == "cash":
total += 12.0
return total
Signal 1: here you need to look carefully. The conditional is in one file. But the knowledge —how much each provider charges— belongs to the providers, which already live in payments/ with their own factory. So the knowledge really is scattered: part in payments/, part here.
Verdict: yes, but not a new factory. It's lesson 3's exercise 3: the fee moves to the provider as a piece of data, and this script walks the registry that already exists. The answer wasn't "add a pattern" but "move the knowledge to where its owner already lives." That move —cohesion, not indirection— solves more problems than any pattern, and almost never gets taught because it has no catalog name.
Case 4 — Building the confirmation email.
def build_confirmation_email(order, customer, event) -> Email:
return Email(
to=customer.email,
subject=f"Your purchase for {event.name}",
body=render_template("confirmation.html", order=order, customer=customer, event=event),
attachments=[ticket_pdf(t) for t in order.tickets],
reply_to=event.organizer_email,
)
Five fields, one of them calculated, another a list. Someone looks at that and thinks "Builder."
Signal 1: no, one place. Signal 2: all the data arrives together, no step does cumulative work —render_template and the attachments get calculated, but don't depend on the email's partial state. Signal 3: render_template reads from disk and ticket_pdf generates a file; both are candidates.
Verdict: no Builder at all. A dataclass for Email, and if you want fast tests, inject the renderer. It's Builder's most common false-positive case: many fields isn't the same as complex construction.
Case 5 — Check-in logging at the event door.
class CheckInService:
def __init__(self):
self._db = Database(settings.DATABASE_URL)
self._scanner = BarcodeScanner(port="/dev/ttyUSB0")
self._printer = WristbandPrinter(ip=settings.PRINTER_IP)
Signal 1: no. Signal 2: no. Signal 3: all three dependencies cross boundaries: database, a hardware port, and a network printer. To test this class today you need a physical scanner connected.
Verdict: injection, all three, no discussion. It's the clearest of the five cases. Notice there's no catalog pattern involved here at all: just moving three lines from the constructor's body to its parameters.
What to expect from these five cases. Count them: of five corners where the trained reflex screams "pattern," none needed a new Factory, none needed a Builder, two needed dependency injection, one needed knowledge moved to a different place, and two stayed exactly as they were.
That proportion isn't a trick of the example: it's roughly what you're going to find at work. Dependency injection is the only one of the module's three techniques used often. Factory gets used when there genuinely is a scattered decision, which happens but not every day. Builder is rare in Python. And a huge portion of code needs nothing.
And notice case 3, because it's the most instructive of the five. The problem was real, but the solution wasn't adding structure: it was moving knowledge to where it belongs. That move has no pattern name, and that's why almost nobody considers it first. Before asking yourself what pattern to apply, ask yourself whether something is simply in the wrong place.
How to measure the cost of ceremony
A practical problem: in a code review, "I think this is too much" sounds like opinion and convinces nobody. You need a measurement.
Here are two that work.
Measurement 1 — Count the reading jumps. Take a concrete question someone new would ask, and count how many files need opening to answer it.
"What exactly happens when a customer pays with cash?"
- With lesson 1's code (everything in checkout): one file. Easy to answer, hard to maintain.
- With lesson 3's factory: three files —checkout, the factory, the provider. It's the price we pay, and we pay it knowingly.
- With a plugin architecture with dynamic discovery: five or six files, and one of them you have to run mentally to know what got registered. That's module 2's
plugins/corner.
Three jumps for a concrete gain is a good trade. Six jumps for nothing is the antipattern.
Measurement 2 — Count ceremony lines per line of work. In any abstraction, separate the lines that do something from the ones that just connect.
# Lesson 4's TicketBuilder:
# 30 ceremony lines (the with_x's, the builder's __init__)
# 4 work lines (the four fields that end up in the Ticket)
# Ratio: 7.5 to 1 → bad signal
# Lesson 3's providers factory:
# 12 ceremony lines (the registry, the error, the function)
# ~90 work lines (the three provider classes, which would exist regardless)
# Ratio: 1 to 7.5 → good signal
When ceremony beats work, the abstraction weighs more than it carries. It's a crude measurement, and that's why it's useful: it's calculated in twenty seconds and can go in a review comment without sounding like personal taste.
The table that sums up the module
Keep this one, because it's the whole module on one screen:
| Your situation | The answer |
|---|---|
| A data object with fields, that simple | Constructor. dataclass if you want __repr__ and __eq__ for free |
| Many fields, several optional | dataclass with defaults and kw_only=True |
| The above plus rules crossing fields | __post_init__ |
| A pure function calculating something | Nothing. Leave it alone |
A two-branch if that isn't going to grow, in one place | Nothing. The if is fine |
The same if over the same options, in 3+ places | Factory |
| The data arrives at different times, or the steps do work | Builder |
| The object talks to network, disk, database, clock, or chance | Dependency injection |
| You want a single instance of something expensive | Create it at startup and pass it. Never a Singleton |
| "Someday we're going to need another one" | Nothing, today. Wait for the second real case |
| The knowledge is in the wrong file | Move it. No pattern needed |
Notice how many rows say "nothing." Five of eleven. That proportion is the lesson's message.
Common mistakes
Applying the vaccine backwards and leaving everything unstructured (judgment). What happens: someone leaves this lesson convinced patterns are suspicious, and so lets the cases where they genuinely were needed slide by. The providers if grows to seven branches in five files, and every time someone points it out, the answer is "don't abstract prematurely." Under-structuring has a cost just as real as over-structuring; it's just less visible because it's paid in small daily doses. Why it happens: "don't abstract" is easier to remember than "abstract when these signals are met," and when in doubt people stick with the simple rule. How to spot it: if your team has spent months saying "we should fix that if" and nobody fixes it, you're no longer avoiding premature abstraction: you're avoiding the work. How to fix it: the signals are a filter in both directions. If one is met, clearly, the pattern earns its place, and postponing it also has a cost.
Confusing "simple" with "short" (conceptual). What happens: someone avoids a factory and ends up with a 200-line function with seven nested conditionals, and defends it saying "it's simpler, it's all in one file." It's all in one file, yes, but nobody can read it. Why it happens: the criticism of indirection gets mentally translated into "fewer files is better," and that's not what it says. How to spot it: ask someone unfamiliar with the code to explain what that function does. If it takes them more than three minutes or they get lost, it isn't simple. How to fix it: the measure isn't the number of files or lines, it's how much has to be held in your head at once to understand a part. A 200-line function demands loading it all. Three 30-line files, honestly named, don't. Module 2 states it precisely: the tradeoff is between coupling and indirection, and both hurt —the skill is choosing which hurts less here.
Using this lesson to reject other people's changes without offering anything (communication). What happens: someone shows up to a review with "YAGNI" and a link, and rejects an abstraction their teammate spent two days thinking through. They might be right at the core and still leave a team worse off: next time that person proposes nothing. Why it happens: short rules are convenient to cite and feel objective. How to spot it: if your comment doesn't mention the concrete problem the other person was trying to solve, you're citing instead of conversing. How to fix it: acknowledge the problem, contribute the missing data —how many implementations exist today, how many places decide— and propose the minimal version. "I agree the if is going to grow. Today there are two cases and only this file decides; should we leave it and extract the factory when the third one arrives? I'll leave the comment with the condition so we don't miss it if you want." That's shared judgment; the other thing is a verdict. Module 7 develops this difference in depth.
Exercises
Exercise 1 — Apply the filter to four cases. For each one, evaluate the three signals and give a verdict: ordinary constructor, dataclass, Factory, Builder, or injection. Justify with the signal that decided.
(a) SeatMap: represents a venue's seating map. Has rows, sections, and a list of seats with their status. Built by reading a JSON file for the venue. Used in three places.
(b) Invoice: an order's invoice. Assembled with the customer's tax data, the line items —one per ticket— the taxes, and a folio number. All data is available when it's issued. There's a rule: the line items' total plus taxes must match the order's total.
(c) EventSearchQuery: filters events by city, date range, category, and max price. The user applies them one at a time in the interface, and each added filter triggers a new search.
(d) TimezoneConverter: converts dates between the venue's timezone and the user's. Uses the standard library. Called from eight places.
See solution
(a) SeatMap — injection, but not of the map: of the loader. Signals 1 and 2: no. Signal 3: yes, reading a JSON file crosses a boundary. Now the nuance that makes the diagnosis good: what gets injected isn't the SeatMap —that's a data object built normally— but whoever loads it. Separate the two things: SeatMap is a pure dataclass and load_seat_map(venue_id) is the function that touches disk. That way seating-logic tests build SeatMap by hand, no files. When a data object "gets built by reading something," there are almost always two objects in there: the data and the loader.
(b) Invoice — dataclass with __post_init__. Signal 1: no. Signal 2: the data arrives together, so there's no step-by-step construction. The matching rule does cross fields, and that's exactly what __post_init__ solves. Signal 3: the folio probably comes from an external service or a database counter —that does get injected, or better, gets calculated upstream and passed as data. This case is set up to reinforce lesson 4's point: a rule crossing fields isn't enough on its own to justify a Builder.
(c) EventSearchQuery — Builder, and it's the only one of the four. Signal 2, first criterion: the filters arrive at different times, as the user applies them. And there's an additional benefit this case makes clear: the accumulated object can be serialized into the URL, which is how real search engines work. Though it's worth being honest here: if every with_x is just an assignment, an immutable dataclass with a replace() method —dataclasses.replace(query, city="CDMX")— does the same with less code, returning a new object each time, which also avoids shared-state bugs. Even where Builder applies, Python often has something shorter.
(d) TimezoneConverter — nothing. A constructor or a standalone function. None of the three signals. Uses the standard library, deterministic, crosses no boundary —time zones come with Python. Eight places calling it isn't a signal of anything: it's a useful function. This case is here because "used in many places" is deceptive: the number of uses isn't a signal; the number of places that decide is.
Why it works: four cases, only one catalog pattern applied —and with reservations. If your first instinct was to reach for a pattern in three or four, that's the exercise's valuable finding, and it's exactly the new-hammer syndrome this lesson comes to treat.
Exercise 2 — Dismantle the over-engineering. This code showed up in a Boletia pull request. Strip out everything unnecessary without losing any functionality, and write how many lines and files you saved.
# File: notifications/channel_factory.py
class NotificationChannelFactory:
def __init__(self):
self._registry = {}
self._register_defaults()
def _register_defaults(self):
self._registry["email"] = EmailChannelProvider()
def register(self, name, provider):
self._registry[name] = provider
def create(self, name):
provider = self._registry.get(name)
if provider is None:
raise ValueError(f"Unregistered channel: {name}")
return provider.provide()
# File: notifications/channel_provider.py
class ChannelProvider(ABC):
@abstractmethod
def provide(self): ...
class EmailChannelProvider(ChannelProvider):
def provide(self):
return EmailChannelBuilder().with_host(settings.SMTP_HOST).build()
# File: notifications/channel_builder.py
class EmailChannelBuilder:
def __init__(self):
self._host = None
def with_host(self, host):
self._host = host
return self
def build(self):
return EmailChannel(smtp_host=self._host)
# Usage, in the only place in the system that sends notifications:
channel = NotificationChannelFactory().create("email")
channel.send(customer.email, message)
See solution
The equivalent version:
# File: app.py — in build_services()
notifier = EmailChannel(smtp_host=settings.SMTP_HOST)
# Usage:
notifier.send(customer.email, message)
Savings: three files and about 35 lines, replaced by one.
Let's count what was there and what each piece bought:
- A Factory for a registry with one single entry. Decides nothing:
create("email")always returns the same thing. Lesson 2's number one common mistake. - A
register()nobody ever calls. It's a miniature plugin architecture, for a single plugin —module 2'splugins/corner wearing a different outfit. - An abstract
ChannelProviderclass with a single implementer. Module 2 lesson 6's smell. - An
EmailChannelProviderwhose only method builds something else. A layer that just forwards. - A Builder with a single
with_x, which is an assignment. Lesson 4's literal anti-case.
Five abstractions stacked to call a one-parameter constructor. And —this is the important part— none of this was written carelessly. Every piece, on its own, imitates a legitimate pattern. Whoever wrote it was applying what they'd learned, in good faith. That's the lesson's point: over-engineering doesn't come from ignorance, it comes from enthusiasm.
And now the honest question: what's lost by dismantling it?
Nothing that exists today. But the ability to register channels from outside at runtime does disappear. Does Boletia need it? No: there are three channels, they're known at compile time, and there's no third-party extension requirement. If Boletia ever sold integrations where a customer registers their own channel, that capability would be needed —and that day, it gets built, with that day's information.
What is worth leaving is a one-line note in the pull request where you remove it, explaining under what condition it would come back. That's what turns a deletion into a documented design decision, and it's exactly what lesson 8's project is going to ask you to write.
Exercise 3 — Write the reversal condition. For each of these three "don't abstract" decisions in Boletia, write the concrete, observable condition under which you'd change your mind. It has to be something someone can verify without arguing.
(a) Not extracting a factory for the notification channels, because only notifier.py builds them.
(b) Not injecting the barcode generator, because it's a pure function.
(c) Not using a Builder for Invoice, because all the data arrives together.
See solution
(a) "We extract the factory when a second file needs to build a channel by name. Today only notifier.py does; the most likely candidate is the admin panel once we add the resend-confirmation button."
Verifiable —you count files— and it also names the suspect, which helps someone notice when it happens.
(b) "We inject it if the barcode starts getting generated by an external service, or if the format has to change per venue. As long as it's a deterministic formula inside our own code, it's tested by calling it directly."
Notice the condition is written about the property holding up the decision —being pure and deterministic— and not about a number. When the reason is a property, the reversal condition is that property no longer holding.
(c) "We switch to Builder if invoicing starts happening in steps: for example, if the partial invoices administration asked about show up, where line items get added throughout the month and it closes at the end."
Here the condition cites a concrete requirement already mentioned on the team. That's the most useful kind of condition: someone can recognize it when it arrives.
Why this exercise is the lesson's most important one. A decision not to abstract, written without its reversal condition, is indistinguishable from not having thought about it at all. Six months later, when someone looks at the if that grew, they won't be able to tell whether it was a decision or an oversight —and they're going to assume the latter, because it's the more common one.
With the condition written down, that same decision becomes two valuable things at once: evidence there was judgment and a trigger for the future. Someone reads "when a second file builds a channel" and knows what to do.
A practical place to put them: a short comment where the code lives, or —better— the pull request description. A three-line comment saying "decision: we're not extracting a factory because only this file decides. Revisit when there's a second one." is worth more than any diagram.
And this is exactly the deliverable lesson 8 is going to ask you for. The project isn't judged by how many patterns you applied: it's judged by what you decided not to do, and why.
Summary and next step
In this lesson you put the filter in front of everything you learned in the module. The core idea: most of a system's objects get built with a constructor and that's it, and creational patterns live at the system's edge —where decisions repeat, where you talk to the outside world— not at its core. Boletia's domain heart —Ticket, Order, Money, Customer— needs nothing.
You have the three signals a pattern genuinely earns its place with, each with its concrete test: the decision repeats —how many files do I open to add an option?— construction is genuinely complex —do my steps do something or just store?— and you need to substitute the piece —am I ever going to pass something different to it? And the three false ones that fool everyone: "there's an if," "someday we're going to need another one," and "this looks more professional."
You applied the filter to five Boletia corners, and the result was what matters: no new Factory, no Builder, two injection cases, one of knowledge sitting in the wrong file, and two that stayed untouched. That proportion is real work's proportion, and it brings the module's most useful side lesson: before asking yourself what pattern to apply, ask whether something is simply in the wrong place. Moving knowledge to where it belongs has no catalog name and solves more problems than any pattern.
And you have two ways to measure ceremony so your opinion in a review doesn't sound like personal taste: counting the reading jumps a concrete question requires, and the ratio of ceremony lines to work lines.
Before moving on you should be able to: apply the three signals to a case you've never seen; dismantle a stack of abstractions without losing functionality; explain why "simple" isn't the same as "short"; and —what's going to weigh most in the project— write the reversal condition for a decision not to abstract, in a way someone can verify without arguing.
Lesson 8 is the project, and now you know why it's framed the way it is. You're going to take the scattered creation of PaymentProvider and NotificationChannel in Boletia and organize it with the minimal pattern that solves the problem, also turning in a short document with what you decided not to abstract and under what condition you'd reconsider. It's judged by the simplicity of the solution, not by its sophistication —and after this lesson, that sentence shouldn't sound like consolation anymore.
Resources
- The Grug Brained Developer — the essay on complexity as the real enemy, told with humor. Its section on the "factory factory" is this lesson in three paragraphs.
- Sandi Metz — The Wrong Abstraction — the essay that popularized the phrase "duplication is cheaper than the wrong abstraction." The core argument behind false signal number two.
- Martin Fowler — Yagni — the principle's precise formulation, with the nuance almost always lost: it isn't "never think about the future," it's "don't build for a future that hasn't been requested."
- Dan North — Introducing Deliberate Discovery — why the information you're most missing is exactly what you'd need to design well, and what to do about it. The theoretical foundation for "wait for the third case."