Shipmind Labs

A notification template is a contract, not a string

· 8 min read

A notification is one of the few outputs in a production system with no undo. Once a provider accepts the payload, the SMS is on a phone, the email is in an inbox, and the push has already lit a lock screen. Yet the body of that message is usually the least-checked value in the codebase: a format string in a module constant, filled from a dictionary assembled three call frames away, with nothing between the two but the assumption that whoever wrote the call site read the string carefully.

We build notification layers often — order confirmations on cross-border commerce platforms, reminders and warnings in a lending product's collections flow, verification outcomes for compliance teams, fan-out from one service to mobile push, SMS, WhatsApp and Telegram. The recurring defect is almost never the transport. Providers are boring: they accept the message or they return an error you can classify and retry. The expensive defect is the message that was delivered successfully and was wrong.

So the question we keep coming back to is not how to send reliably. It is where a notification's contract lives, and what checks it.

The format string promises nothing#

A Python format string is permissive in one direction and fatal in the other, and both behaviours land at send time.

python
TEXT = "Order {order_id} confirmed: {item_count} item(s), total {total:.2f}."

TEXT.format(order_id="A-1042", item_count=3, total=59.9, coupon="SPRING")
# 'Order A-1042 confirmed: 3 item(s), total 59.90.'  — coupon silently ignored

TEXT.format(order_id="A-1042", item_count=3)
# KeyError: 'total'

TEXT.format(order_id="A-1042", item_count=3, total="59.90")
# ValueError: Unknown format code 'f' for object of type 'str'

Three different outcomes for three different call-site mistakes, and not one of them is a check. The extra key is a typo that will never be reported: someone renamed coupon_code to coupon in the template, missed the call site, and the discount line quietly stopped appearing. The missing key is a KeyError raised at the moment of handover. The wrong type is a ValueError from the formatting machinery, phrased in terms of format codes rather than in terms of the variable that was wrong.

The timing is what makes this costly. Notification delivery is backgrounded in every serious system — you do not hold an HTTP request open while a provider decides whether it likes a phone number. That means the exception does not surface in the request that caused it. It surfaces in a worker, detached from the input that produced it, in a log line nobody is watching, while the customer who was supposed to receive the confirmation receives nothing at all. If the failure mode is the permissive one instead, there is no log line to miss, because nothing failed. The message went out with a gap in it.

The declaration is the other half of the contract#

A template has two sides that can disagree: the text, and the set of values the text needs. A bare string carries only one of them, so the other side lives in the heads of the people writing call sites. That is the whole bug.

We wrote notify-dispatch (https://github.com/shipmindlabs/notify-dispatch) around the opposite premise: a template declares the variables it needs and the type of each one, and that declaration is checked against the text at construction time.

python
from notify_dispatch import Template, Variable

confirmation = Template(
    name="order_confirmed",
    text="Order {order_id} confirmed: {item_count} item(s), total {total:.2f}.",
    variables=(
        Variable("order_id", str),
        Variable("item_count", int),
        Variable("total", float),
    ),
)

The duplication here is deliberate and it is the point. The text and the variable tuple are two independent statements of the same requirement, so they can be compared. An undeclared {placeholder} in the text, or a declared variable the text never uses, raises TemplateDefinitionError when the Template is constructed — not when it is rendered, and not when it is sent. Declare your templates at module level in a module your application imports at startup, and a template that contradicts itself stops the process instead of shipping.

At render time the contract is enforced against the values:

python
confirmation.render({"order_id": "A-1042", "item_count": 3, "total": 59.9})
# 'Order A-1042 confirmed: 3 item(s), total 59.90.'

confirmation.render({"order_id": "A-1042", "item_count": 3})
# MissingVariableError: template 'order_confirmed' is missing values for: total

confirmation.render({"order_id": "A-1042", "item_count": "3", "total": 59.9})
# VariableTypeError

confirmation.render({**context, "coupon": "SPRING"})
# UnknownVariableError

Three named errors instead of a KeyError, a ValueError and a shrug. The one that earns its keep in practice is UnknownVariableError, because it converts the silent failure into a loud one. The renamed variable that used to vanish without trace now stops the send.

Types matter here for a reason beyond tidiness. {total:.2f} is a formatting instruction that only works on numbers, and money in a notification is exactly the value you least want rendered by accident. Declaring Variable("total", float) moves that from an incident inside str.format to a rejection with the variable's name attached.

Validation belongs before the handover, not around it#

A contract that is checked inside the provider call is not much better than no contract. The ordering is the invariant: rendering and validation must complete before anything irreversible happens, because the handover to a provider is the step you cannot take back.

One dispatch call renders the template and then delivers it, in that order, with provider adapters per channel behind a single protocol:

python
receipt = dispatcher.send(
    Recipient(phone="+49151000000", email="ada@example.com"),
    confirmation,
    {"order_id": "A-1042", "item_count": 3, "total": 59.9},
)

And the guarantee is a test, not a claim in a docstring:

python
def test_template_errors_surface_before_any_provider_is_called():
    sms = Provider()
    dispatcher = Dispatcher([SmsAdapter(sms)])

    with pytest.raises(MissingVariableError):
        dispatcher.send(Recipient(phone="+491"), ORDER_CONFIRMED, {})
    assert not sms.calls

The second assertion is the one we care about. pytest.raises proves the error happens; assert not sms.calls proves it happens early enough. Any refactor that moves validation after the adapter lookup, or that renders lazily inside the delivery path, breaks that line. This is the kind of test we want in a review gate: it does not check behaviour, it checks an ordering that a future contributor would otherwise have no way of knowing was load-bearing.

Two error classes, two operational responses#

Once the contract is explicit, notification failures split cleanly, and the split is what makes the runtime tractable.

Contract violations — TemplateDefinitionError, MissingVariableError, UnknownVariableError, VariableTypeError — are bugs in our code. They are deterministic, they will fail identically on every attempt, and retrying them is pure waste. They should surface at import, in tests, or at the top of dispatch, and they should never be retried or parked.

Delivery failures are environmental. A provider is down, a token is stale, an address is rejected. These are the failures worth a RetryPolicy with a growing backoff and a retry_on list, and worth wrapping in DeliveryError carrying the channel and address so the log line says which of three providers actually broke. What still fails after the last attempt lands in dispatcher.dead_letters with every attempt that was made, so a lost notification is something you can list and hand to whoever replays it, rather than a line someone has to find in a worker log.

The useful consequence: nothing in the dead-letter queue is ever a template bug. A queue that mixes "the SMS gateway was unreachable for ninety seconds" with "we forgot to pass total" is a queue nobody drains, because half of it is unreplayable by construction. Keeping contract violations out of it is what makes the remainder worth looking at.

What it costs#

The cost is the declaration. Every template is a few lines longer than the string it replaces, and adding a variable means touching the text and the tuple. In exchange, the pair checks itself at construction, so the duplication cannot drift — which is the opposite trade from a comment describing which keys a format string expects.

The other cost is discipline about where templates are constructed. The construction-time check only protects you if the constructors run. Templates defined at module import in a module the application and the test suite both load get checked on every process start and every CI run. Templates built inside the function that sends them get checked only when that path executes, which puts you back where you started, with the rarely-exercised notification as the one that breaks.

A missing order_id costs nothing to fix in a test, a few minutes in CI, and an apology in production. The only variable in that sentence is where you choose to find it.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com