ExampleDifficulty: Intermediate2 min

Visitor Example: Totals and Exports Over an Order's Line Items

Products, services and discounts are visited once for the total and once for an invoice.

An order made of different kinds of line item is a natural Visitor, and this example maps the template onto it. An order holds products, services and discount codes. The total and the printed invoice each need different code for each kind of line, and each becomes a visitor.

The template's parts in the order example
TemplateOrder example
VisitorLineVisitor
Concrete ElementProduct, Service, Discount
Concrete VisitorTotalVisitor, InvoiceVisitor
ClientThe loop over the order's lines
py
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass


class LineVisitor(ABC):
    @abstractmethod
    def visit_product(self, line: Product) -> None: ...

    @abstractmethod
    def visit_service(self, line: Service) -> None: ...

    @abstractmethod
    def visit_discount(self, line: Discount) -> None: ...


@dataclass(frozen=True)
class Product:
    name: str
    unit_pence: int
    quantity: int

    def accept(self, visitor: LineVisitor) -> None:
        visitor.visit_product(self)


@dataclass(frozen=True)
class Service:
    name: str
    hourly_pence: int
    hours: int

    def accept(self, visitor: LineVisitor) -> None:
        visitor.visit_service(self)


@dataclass(frozen=True)
class Discount:
    code: str
    pence: int

    def accept(self, visitor: LineVisitor) -> None:
        visitor.visit_discount(self)


class TotalVisitor(LineVisitor):
    def __init__(self) -> None:
        self.pence = 0

    def visit_product(self, line: Product) -> None:
        self.pence += line.unit_pence * line.quantity

    def visit_service(self, line: Service) -> None:
        self.pence += line.hourly_pence * line.hours

    def visit_discount(self, line: Discount) -> None:
        self.pence -= line.pence


class InvoiceVisitor(LineVisitor):
    def __init__(self) -> None:
        self.rows: list[str] = []

    def visit_product(self, line: Product) -> None:
        self.rows.append(f"{line.quantity} x {line.name:<14} £{line.unit_pence * line.quantity / 100:>7.2f}")

    def visit_service(self, line: Service) -> None:
        self.rows.append(f"{line.hours} h {line.name:<14} £{line.hourly_pence * line.hours / 100:>7.2f}")

    def visit_discount(self, line: Discount) -> None:
        self.rows.append(f"code {line.code:<14}-£{line.pence / 100:>7.2f}")


order = [
    Product("desk lamp", 2499, 2),
    Service("installation", 4000, 1),
    Discount("WELCOME10", 1000),
]

total = TotalVisitor()
invoice = InvoiceVisitor()
for line in order:
    line.accept(total)
    line.accept(invoice)

print("\n".join(invoice.rows))
print(f"total: £{total.pence / 100:.2f}")
OutputRan with python:3.12-slim (Python 3.12.13)
2 x desk lamp      £  49.98
1 h installation   £  40.00
code WELCOME10     -£  10.00
total: £79.98
Each line is visited by both operations in one pass.

Two lamps at £24.99, an hour of installation at £40 and a £10 code give a total of £79.98, and the invoice lists the same three lines with their amounts. Each visitor keeps its own state, the running total or the list of rows, between visits.

Everything about pricing is in TotalVisitor, and everything about layout is in InvoiceVisitor. A third operation, such as tax by line type, is one new class, and the three line classes do not change. A new kind of line, such as a gift card, is the costly direction, since every visitor must learn it.