Quashing volatility with easy design principles
Dear reader, you live in a box. Indeed, we makers and thinkers live in boxes walled off by our domains of practice and expertise.
A piece of software is a domain of knowledge. A domain is a finite space. 📦 It’s sort of a box.
You get to design the box you live in. You can have skylights that maximize solar gain in the winter. An awning at the door so mail doesn’t have to get wet.
Or you can put Venetian blinds wherever. And have couriers shove what they can through a mail slot.
Windows, doors, and portals at the boundaries of your box can make it nice to be in there or…not. The same goes for your code. If you would rather gaze at the harvest moon than attempt to clean Venetian blinds 🤢 read on.
My point, ahead of time
I’ll save the impatient some reading and provide my condensed hot tip:
Be cognizant of your proximity to the boundaries of your application. When you’re there, spend extravagantly on designing well-structured objects.
What do you mean, Ainsley? Let’s chat along the scenic route. ⛰️
A piece of software has boundaries
These boundaries are ever-present but often missed. They represent the “vanishing points” of your control over the application.
What “vanishes” is your certainty of what will happen at runtime.
- Will the database be online?
- Are the credentials valid?
- Will the client send a valid request?
- Has the config file been properly specified?
No way of knowing.
Dear reader, we are now in the kitchen. The box metaphor is over now.
👨🏽🍳
You are a chef. I know, I know it’s very sudden, but you are a chef in The World Class Python Software Café and you are not allowed to enter the dining room. Databases, credentials, client requests, and config files all exist Beyond The Kitchen Door. Customers send in prie-fixe.toml
and you simply have to cope if it’s garbled nonsense.
Within the bounds of the application (in the kitchen), you are in control. Your module, knife
, will irrefutably have a .chiffonade()
member at runtime. You can be certain because you wrote it that way! 🔪
Boundary or not, however, you depend on knife
and prie-fixe.toml
to get your job done.
A side-note on dependencies
“Dependency” is a context-loaded word. We usually think of things downloaded from package managers. But in practice, software is rife with things “depending” on one another.
This is not an article on the conceptual principles of dependence, but I must say one thing:
There are two broad categories of dependency: Stable and Volatile.
Credit to Martin Seeman for the terminology.
The World Class Python Software Café kitchen must receive orders on prie-fixe.toml
. Without specifying which set of courses the patrons would prefer, there would be nothing to cook. The configuration coming in from outside the kitchen (outside the application boundary) is a volatile dependency because the good functioning of the application depends on it, but its precise structure or state at runtime cannot be controlled.
Even with a stack of well-formed ––and well-chosen; the beignets are incredible–– prie-fixe.toml
s, you won’t get far without the trusty knife
module. You depend on your knife constantly. The implementation of knife
exists within the boundaries of your application, therefore it is stable. Code and modules from a trusted author (including yourself) are stable dependencies.
Back to the boundary
Why the dependency aside, dear author?
Well, dear reader, volatile dependencies and application boundaries have a fairly predictable interplay: Most volatile dependencies appear at application boundaries.
The Venn diagram of “Things at application boundaries” and “Volatile dependencies” is nearly a circle.
Poor handling of volatile dependencies ruins everything
For a while, we would simply ignore every improperly-formed prie-fixe.toml
that came into the dining room. Our Yelp reviews plummeted. Ruined!
We also had issues with dietary restrictions. Our system was to simply have servers remember them and then yell confidently to the line cooks that an order just came in. Lawsuits came. Ruined!!!
Ruin is often avoidable
Like most rules, they’re secretly just guidelines. Salt, please.
- When you reach application boundaries, design a new type (class, object).
- Instantiate your new type rarely. Instead, consume it in type-hinted function/method signatures.
- When Rule #2 is not sensible, create your instances as close to the boundary as possible.
Let me explain.
1. When you reach application boundaries, design a new type (class, object).
Don’t do this:
def load_prie_fixe_toml():
return {k: v for k, v in toml.load('prie-fixe.toml')} # fake toml library
Because there’s no way for the reader to know the expected contents or shape of the file without going and reading it. :nauseated_face:
Write way more code instead. In real life, I’d use a Pydantic model, so I’ll use it here too.
class Course(BaseModel):
name: str
notes: list[str] = []
class PrieFixeToml(BaseModel):
table_number: int
guest_name: str
courses: list[Course]
class PrieFixeTomlError(Exception):
"""Raise for malformed prie-fixe."""
def load_prie_fixe_toml(path: str = 'prie-fixe.toml'):
try:
return PrieFixeToml(**toml.load(path))
except ValidationError as e: # a pydantic exception
raise PrieFixeTomlError("Malformed prie-fixe. Tell the waiter!") from e
Many things are drastically improved:
- Assumptions about the resource beyond the boundary are codified in the app. For example, there was no way for a toml file to tell us that
notes
is optional. - A malformed resource will break immediately. Before, some far away caller would be the one to discover that a key is missing or specified incorrectly in the file.
- A lucid error codifies domain knowledge. In this case we know that a malformed
prie-fixe.toml
must halt the application. It’s a big deal! - Bonus: Note how
path
was lifted to the signature ofload_prie_fixe_toml
. A path of any kind is a volatile dependency, and thus should be received rather than instantiated. (The constructor forstr
is"
)
2. Instantiate your new type rarely, instead consume it in type-hinted function/method signatures.
Don’t do this:
def yell_order_summary():
order = load_prie_fixe_toml()
print(f"{order.guest_name} ordered {len(order.courses)} courses!!!!!")
def repeat_order_detail():
order = load_prie_fixe_toml()
print(f"Order for table #{order.table_number}, guets {order.guest_name}")
for c in order.courses:
print(f"They want {corse.name}")
if c.notes:
print(f"Guest specified {course.notes}")
Do this instead: Don’t arbitrarily initialize a volatile resource, receive it instead.
def yell_order_summary(order: PrieFixeToml):
...
def repeat_order_detail(order: PrieFixeToml):
...
Many things are drastically improved:
- The lifetime of
order
is now managed. Before, it was read from disk twice. Now, callers can pass around the same instance or make new ones as needed. (That’s “inversion of control”.) - These functions gained documentation. Types are probably the best form of documentation in Python.
- Shared functionality is now obvious. Since these functions have the exact same signature, it’s obvious that they’re cut from the same cloth.
3. When Rule 2 is not sensible, create your instances as close to the boundary as possible.
Rule 2 is almost the logical inverse of Rule 1.
PrieFixeToml
in the examples above is singleton-ish. It carries context throughout the cycle of taking an order and preparing it. For that reason, we lifted __init__
up to a single point of ingress and then received it in function signatures.
For record-like objects ––distinctly un-singleton-ish–– push __init__
calls all the way down to the point of ingress.
Let’s say you’ve been good and wrote an interface at a boundary.
from itertools import chain
class Fridge:
_content: dict[str[list]]
def __getitem__(self, item_name) -> list:
return self._content[item_name]
def get(self, item, default=None):
return default if item not in else self.content_[item]
The fridge has tons of arbitrary content, so adding exhaustive types to Fridge
is hard.
But, we have domain knowledge about the contents, so we often know what’s there.
def fetch_basil(fridge: Fridge) -> list: # nice injection, bad return.
return fridge['basil']
Don’t do this :point_up:
Look at a caller to see why:
import knife # knife is not a volatile dependency, it's a purpose-built module.
# no need to inject.
def do_prep(fridge: Fridge):
basil = fetch_basil(fridge)
largest_leaves = (
leaf
for sprig in basil
for leaf in sprig["leaves"]
if leaf["size"] != "small"
)
return knife.chiffonade(largest_leaves) # 🙅🏽♂️
The assignment of largest_leaves
derives structure to the bunch of basil. But this structure is now documented only in do_prep
. At the point of basil ingress (fetch_basil
), there is no way of knowing that there are leaves of a particular size, let alone that the big ones are special.
Providing structure after the point of ingress is flimsy. If we start storing basil leaves separately from stems, do_prep
(and similar, scattered callers of fetch_basil
) will have to reflect the change in their logic
Again, the solution is to write way more code. (More Pydantic, just because it’s illustrative.)
class BasilLeaf(BaseModel):
size: Literal['small', 'large']
class SprigOfBasil(BaseModel):
leaves: list[BasilLeaf]
@property
def largest_leaves(self):
for leaf in self.leaves:
if leaf.size != 'small':
yield leaf
Great, now there’s a basil structure!
You could do this:
def do_prep(fridge: Fridge):
basil = (SprigOfBasil(**b) for b in fetch_basil(fridge))
largest_leaves = (leaf for sprig in basil for leaf in basil.largest_leaves)
return knife.chiffonade(largest_leaves)
But still, the caller of the fetcher is responsible for structuring. Replacing []
s with .
s is not enough.
Providing even fancy structure after the point of ingress is flimsy. :open_mouth: We need even more code.
fetch_basil
is the point of ingress. And it’s apparent that fetch_basil
should return some collection of SprigOfBasil
, and that the sum of all large leaves is important. Python has tools for this
from collections.abc import Iterable
class FetchedBasil(Iterable):
def __init__(self, sprigs: Iterable[Any]):
try:
self._sprigs = [SprigOfBasil(**s) for s in sprigs]
except ValidationError as e:
raise TypeError("Gross unusable mushy sprigs of basil.") from e
def __iter__(self):
return iter(self._sprigs)
@property
def largest_leaves(self):
for sprig in self:
for leaf in sprig.largest_leaves:
yield leaf
Armed with this new concrete collection, use it as soon as possible.
def fetch_basil(fridge: Fridge) -> FetchedBasil: # nice injection, bad return.
return FetchedBasil(fridge.get('basil', [])) # bonus! handle missing basil.
And finally, simple prep.
def do_prep(fridge: Fridge):
basil = fetch_basil(fridge)
return knife.chiffonade(basil.largest_leaves)
“Grab the basil and chiffonade its largest leaves” is an easy set of instructions for any line cook. Leave fridge perusal to the prep staff.
Be cognizant of your proximity to the boundaries of your application. When you’re there, spend extravagantly on designing well-structured objects.
Boundaries:
- The kitchen door––where prie-fixe orders come from.
- The fridge––where the presence and arrangement of basil are uncertain.
Dependencies:
- If they’re volatile (born from beyond the boundary), treat them with care.
- If they’re stable (constructs from within the application), don’t even write much about them in this article.
Extravagant spending:
- Nowhere in the manifesto does it say “Short is better than long.” Brevity is cool, but clarity is professional.
- Spend keystrokes on types. It’s a high-return investment.
Whatever you eat today, enjoy your meal!
Until next time, :baguette_bread: