Card 04/ 09

ExampleDifficulty: Intermediate2 min

Builder Example: A Report With Optional Sections

A title is required; a chart, a summary and a footer are each optional.

A report generator is a natural Builder: every report has a title, and any report may add a summary, a chart and a footer. This example maps the template onto it, and shows the builder used both as a chain and across several statements.

Template participants and the classes that play them
ParticipantClass in this example
ConcreteBuilderReportBuilder, with title, summary, chart and footer
ProductReport, a frozen dataclass with render()
BuilderNone: with one way to build a report, there is no interface
DirectorNone; the calling code chooses the steps
py
from dataclasses import dataclass
from typing import Optional


@dataclass(frozen=True)
class Report:
    title: str
    summary: Optional[str]
    chart: Optional[str]
    footer: Optional[str]

    def render(self) -> str:
        lines = [f"# {self.title}"]
        if self.summary:
            lines.append(self.summary)
        if self.chart:
            lines.append(f"[chart: {self.chart}]")
        if self.footer:
            lines.append(f"-- {self.footer}")
        return "\n".join(lines)


class ReportBuilder:
    def __init__(self) -> None:
        self._title: Optional[str] = None
        self._summary: Optional[str] = None
        self._chart: Optional[str] = None
        self._footer: Optional[str] = None

    def title(self, text: str) -> "ReportBuilder":
        self._title = text
        return self

    def summary(self, text: str) -> "ReportBuilder":
        self._summary = text
        return self

    def chart(self, data_name: str) -> "ReportBuilder":
        self._chart = data_name
        return self

    def footer(self, text: str) -> "ReportBuilder":
        self._footer = text
        return self

    def build(self) -> Report:
        if not self._title:
            raise ValueError("a report needs a title")
        return Report(self._title, self._summary, self._chart, self._footer)


weekly = (
    ReportBuilder()
    .title("Weekly sales")
    .summary("Orders rose 12% on last week.")
    .chart("orders_by_day")
    .footer("Generated on Monday")
    .build()
)
print(weekly.render())
print()

builder = ReportBuilder().title("Stock check")
low_stock = ["kettle", "toaster"]
if low_stock:
    builder.summary("Low: " + ", ".join(low_stock))
print(builder.build().render())
print()

try:
    ReportBuilder().chart("orders_by_day").build()
except ValueError as error:
    print("refused:", error)
OutputRan with python:3.12-slim (Python 3.12.13)
# Weekly sales
Orders rose 12% on last week.
[chart: orders_by_day]
-- Generated on Monday

# Stock check
Low: kettle, toaster

refused: a report needs a title
The second report is assembled across statements, and its summary is added only when there is low stock.

The weekly report is built in one chain, and every optional part is named where it is set. The stock check shows the case a chain cannot: the builder is created first, the summary is added only if the condition holds, and build() is called once everything that applies has been decided. The third call sets a chart and no title, and build() refuses it.

The builder here has no interface and no director, which is the form most builders take: one concrete builder that the client drives directly. The participants that remain, a builder that collects parts and a build() that checks them, are the ones that do the work.