Design Pressure: The Invisible Hand That Shapes Your Code

By Hynek Schlawack, PyCon US 2025

Good afternoon. I'm Hynek, a software engineer at a boutique German web hosting company called Variomedia. My boss told me not to call it small anymore, so "boutique" it is. I'm also a burned-out open source maintainer and a future famous YouTuber—you knew me before I was cool.

Today, I'm here to talk about software design and the pressures on your precious applications that you might not be aware of, pressures that affect your design in ways you might not like.

Design Pressure

But first, let's make sure everyone is awake.

The use of ORMs and class-based validation frameworks ruins your projects.

How's that for a hot take?

In YouTube land, we call this an attention hook to stop people from closing the video, for the cursed retention graph. Depending on whether you deliver on it or not determines whether you are a great filmmaker or a scammy baiter. So let's see if I can pay down that bait debt and see in what group I fall.

Let's start by adding a tiny bit of nuance.

The unreflected use of ORMs and class-based validation frameworks ruins your projects.

Django, SQLAlchemy, Pydantic, Marshmallow, and whatnot—they are all fine, as long as you're aware of what effect they have on your code. And that's today's topic.

The concept of design pressure is not specific to this area. I will use other examples that have nothing to do with those two, so to be clear, there will be something in here for everybody. I just want to have something concrete to hang my hat on. This is not a talk about tools; this is a talk about architecture.

Not here to tell you to stop using your favorite tool.

Therefore, I'm also not here to tell you to stop using your favorite tool. You can relax. You can stop messaging Mike and Samuel; the Django Software Foundation doesn't know where I live anyway, so don't waste your time. My emphasis is not on ORMs or validation frameworks; my emphasis is on the "unreflected."

My goal for today is to make you aware of the trade-offs that you're making—trade-offs that you're most likely doing subconsciously.

"Until you make the unconscious conscious, it will direct your life and you will call it fate."
— C.G. Jung

As good old Carl Gustav already knew, this is not great for you. Design decisions need to be deliberate, not a matter of perceived fate. To get there, we have to clarify one thing first:

Why do we write software?

Other than for money, of course. Is it to access a database? Or is it to validate some data coming through a web API? Probably not. I'm sure there's someone in here who does exactly that, writing some database driver or something, but usually, it's not. We usually write software to achieve a goal using those tools. And the core logic that does that is often called:

Business logic

As with everything in software engineering, there's a passionate discussion about what exactly is business logic. But this is my talk, my room, my rules. For me, it is the code that determines what to do based on:

  • The data coming from the database.
  • Some kind of user input (e.g., a form, a web API).
  • Most importantly, business rules.

The term "business logic" harks a little bit back to the times when computers were huge and only stood in banks. But your business might also be curing cancer. Business logic doesn't have to be about shareholder value, as we've learned today in the keynote. (I was a little bit afraid of making political statements, but I feel the window opened significantly this morning.)

In many cases, business logic is just a bunch of if statements buried somewhere in a web view or a CLI endpoint, mixed up in form validation and database access.

1
2
3
4
5
6
7
8
if form.valid():
    user = db_session.execute(
        select(User).where(User.id == form.user_id)
    )
    # ...
    if user.something and form.something_else: # This is the business logic
        ruin_life(user)
    db_session.commit()

This is especially true for tutorials that make you implement simple blog engines or to-do apps, famously, because they're teaching you a framework. They don't want to distract from the framework they're teaching with complex out-of-framework logic. Thanks to that, many beginners haven't ever seen explicit business logic in their whole lives.

But it is the code why the application ultimately exists. It is the code that can have grave real-life consequences for actual living humans. Like when you're using the wrong radiation dose.

Therac-25

I hope every programmer has heard of Therac-25. It is a radiation therapy machine from the 1980s, famous for having a race condition that caused some patients to receive 100x the radiation they were supposed to get. As you can imagine, our mushy bodies don't deal well with that, so people literally died.

Maybe people won't die from a radiation overdose if you screw up your business logic, but it might still translate into real-world calamities, like the recent:

British Post Office scandal

Where Fujitsu accounting software accused innocent sub-postmasters of stealing. 900 of them were prosecuted for theft, fraud, and false accounting. 236 went to prison. The fallout on their private lives was even worse, of course.

So, the business code is the most important code of your application, and you have the moral imperative and self-interest to make it as correct as possible. That's where design pressure comes into play because it can make that job harder or easier.

Design pressure

I encountered this term for the first time in J.B. Rainsberger's seminal talk, "Integrated Tests Are a Scam" (or "Integration Tests Are a Scam"). He talks there about how growing the scope of a system under test (e.g., instead of testing a class, you test a whole service layer or end-to-end) limits the design feedback from your tests about your code. In other words, it's masking the pain of a bad design. You get away with highly tangled, highly coupled code because you don't feel the pressure in the form of pain—the pain of testing coupled code.

What exactly is coupling?

Two pieces of code are coupled if they can only be understood by looking at both.

Two pieces of code (classes, modules, whatever) are coupled if they can only be used and understood by looking at both. Changing one usually forces you to change the other one too. Coupling makes it harder to test, change, and understand your code, which is really bad in the long run.

You shouldn't have to understand the intricacies of an ORM or, Cthulhu forbid, a web framework when trying to understand the logic that decides the amount of radiation you're going to blast at a human being.

Since you're all writing tests the whole time (right?), you get the feedback that your code is too coupled from the get-go, not a year later when your boss wants a new feature and you don't know how to add it because you have to change five classes in six files. Or when hectically trying to debug your code because something terrible happened and you might go to jail (ask Volkswagen). This point has been made many times before:

Testable code is better code.

Testable code is better code because it forces decoupling on you. That's the pressure. While there is the concept of so-called "test damage" (complexity in your production code that only exists to facilitate testability), and clearly too much of that is also bad for you, overall testing is a force of good.

This is our first design pressure of the day, and it's a good one. Having to write tests without monkey patching and mocking half of the universe leads to less coupled code by being painful if you don't do so, which makes the code easier to change and understand—which is good.

In fact, I've heard arguments that the main function of writing tests is specifically the design pressure for better software design, and the tests themselves are just a nice side effect that you didn't delete afterwards. I wouldn't go that far, but they've got a point.

I hope this part was relatable to everyone because it doesn't matter what kind of code you write; we all run into testability pain. It's natural. Acknowledging that pain and letting it guide you, instead of claiming that you're the one snowflake whose code cannot be tested, will improve almost everyone's life. So, pain, in this case, is good.

Next, we'll look at a design pressure that's neither good nor bad. I will use a great talk I saw at PyCascades 2024 to illustrate it. It is called:

The Rising Sea

In it, Matt Drury shows a very elegant solution to a problem from Advent of Code—our beloved tradition of vowing to use it to learn a new language and giving up on day six because, surprisingly, this year too, life is too stressful. (It's too much work, but next year, next year we will learn Rust!)

He frames it as a conflict between two mathematicians. I recommend everyone watch it; it's both entertaining and enlightening. I will not spoil the entertaining part, just the educational one.

Your input is a Harbor with stacks of boxes. The letters in square brackets are boxes stacked on each other. Each stack has a number from 1 to 9.

Harbor:
[T]     [Q]             [S]
[R]     [M]     [L] [V] [G]
[D] [V] [V]     [Q] [N] [C]
[H] [T] [S] [C] [V] [D] [Z]
[Q] [J] [D] [M] [Z] [C] [M] [F]
[N] [B] [H] [N] [B] [W] [N] [J] [M]
[P] [G] [R] [Z] [Z] [C] [Z] [G] [P]
[B] [W] [N] [P] [D] [V] [G] [L] [T]
-------------------------------------
 1   2   3   4   5   6   7   8   9

Then you have Instructions telling you what to do with those boxes, like: move 5 from 4 to 9.

Channeling one of the mathematicians, he gives himself a design maxim:

"You should structure your data so that the problem solves itself."
— Matthew Drury

He then models the problem using nice, clean classes:

  • An Instruction class that encapsulates a move.
    1
    2
    3
    4
    5
       @dataclass
       class Instruction:
           source: int
           destination: int
    алкоголь        count: int
    
  • A Harbor class that consists of Stacks and executes Instructions.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @dataclass
    class Harbor:
        stacks: List[Stack]
    
        def execute(self, i: Instruction):
            source = self.stacks[i.source]
            destination = self.stacks[i.destination]
            payload = source.popsome(i.count)
            destination.extend(payload)
    
  • A Stack class that consists of Boxes and allows adding/removing boxes from the top.
    @dataclass
    class Stack:
        boxes: List[Box]
    
        def popsome(self, count: int) -> List[Box]:
            tail = self.boxes[-count:]
            self.boxes = self.boxes[:-count]
            return tail
    
        def extend(self, some: List[Box]):
            self.boxes.extend(some)
    

The boxes themselves don't matter; it's just some object represented by letters (could be strings). These classes combine the business logic of harbor box management with the data structure to work on. This makes it the:

Domain Model

"In software engineering, a domain model is a conceptual model of the domain that incorporates both behavior and data."
— Wikipedia

A conceptual model in Python is, in a very hand-wavy way, just a fancy word for a bunch of classes and how they relate to each other. A domain model tries to reflect the domain in which you're solving your business problems (in this case, a harbor).

Meanwhile, a data model is something completely different. It's, for all intents and purposes, the database schema. It annoyingly sounds almost the same, and many people can't tell the difference because they've often never seen a domain model. This constant reuse of terms is one of the biggest sins of software engineering. It seems like all important concept names are somehow permutations of "model," "event," "domain," and "service."

This domain model is your precious. No matter how small (and it tends to be small compared to the rest of the application, ironically), it's the reason your application exists. Coming up with a domain model is called domain modeling. The quality of a domain model is a function of how well it reflects the domain and how understandable it is, which Matt's code delivers on totally. It even fulfills the platonic ideal of a domain model by being completely isolated from everything not part of the domain.

On one look, you can identify the entities and their relationships (Instruction, Stack, Harbor) and understand the business logic they implement. It doesn't even need conditionals like if statements, which is always a very good sign (they add cyclomatic complexity).

Now, we've got this pretty cool domain model—a bunch of classes implementing business logic without knowing how they got created or persisted. But then, as a plot twist, he shows us more code. This code he calls:

The Darkness

def parse_harbor(stackstr: Iterable[str]) -> Harbor:
    bottomup = list(stackstr)[:-1]
    harbor = Harbor([[] for _ in range(N_STACKS)])
    for line in bottomup:
        tokens = [
            ''.join(x for _, x in g[1])
            for g in groupby(
                enumerate(line),
                lambda t: t[0] // N_CHARS_IN_BOX_TOKEN
            )
        ]
        boxes: List[Box] = [t.strip('[] ') for t in tokens]
        for box, stack in zip(boxes, harbor.iter_stacks()):
            if box: stack.append(box)
    return harbor

Please don't try to understand it. This code is necessary for the elegant code from before to do its work because it parses the input from Advent of Code and munches it into nice, clean harbor objects. But this code has nothing to do with the problem domain. Not at all. This is just a parser. The actual crane-moving code only gets to be elegant because it knows nothing about this filth.

In a way, this is a buffer between the data coming in and the business logic. Matt concludes:

"The shitty stuff should happen on the outside layer of your program, that interacts with the 'outside world'. Once it's inside, organize it into nice data structures so that operations (i.e. code) are as self evident as possible."
— Matthew Drury

Beautiful. This is the key lesson. It's another way of saying "validate at the edges" and "don't validate, but parse." He also goes a step further and says to reshape your data at the edges too, so that it serves your problem.

This buffer between layers doesn't have to be a parser you wrote yourself. It can be a function that takes input from another parser (like Pydantic) and transforms it into beautiful harbor objects. Or it can be some kind of database access. It doesn't matter. What matters is that the data is coming in, and you are taking agency over the shape of the data before you work with the data.

Because, what we've seen is that:

The shape of the data determines the shape of the code.

That's design pressure right there. If you mix parsing with executing, this code would be much worse, and the business code much harder to understand or even find. But Matt removed this pressure completely by converting the input into something well-suited to his problem first. So the business logic is completely decoupled from parsing. The shape of the input (which you have no power over) has no influence on the shape of the business code.

As promised, still no ORMs or validation packages. This is relevant for every single one of you. This is like Computer Science 101, but we don't talk about this anymore.

With this in mind, let's talk about ORMs and validation frameworks.

Problem #1: Conflicting Goals

They both tend to call their classes "models," which, fair enough, there are like five terms you're allowed to use. But those models have widely different, conflicting design goals.

  • Web API: The shape of validated data is dictated by the shape of your API. This is shaped by what's best for the user or what is a good standard (e.g., JSON:API). What people want to post is almost never what I want to work with.
  • Database schema: The shape of ORM models is dictated by the shape of the data model (database schema/table design). This involves considerations like how to store data effectively, how to normalize, and the trade-off between storage vs performance. Especially SQLAlchemy has a lot to offer to munch table data into something convenient. (Django's ORM has a pony.) A database schema has very different design considerations. In old-school companies, DBAs design these and don't care about your business code; they care about indexes and dead tuples. Fair enough, that's their job.
  • Domain model: Your ideal domain model is dictated by the shape of the problems you're solving—how to solve the business requirement—and nothing else. Since it's the most important code, you ideally want all the help from your language and ecosystem (like making illegal state unrepresentable, or static typing, which is a design pressure on its own, as Łukasz Langa told us).

This gets difficult when, for example, Django's ORM forces you to pass strings to name fields if you don't want to overfetch in model.objects.values() (and there's not much hope to make that type-safe). If you don't think overfetching is a problem, a Django dev friend told me someone found out the hard way there's a limit of 1,664 columns in a Postgres query. Careless joins, wild denormalization, and not limiting columns will do that.

My business code also needs different kinds of information depending on the entity's state. For invoicing:

  • OpenInvoice: needs due_on: date
  • PaidInvoice: needs paid_on: date
  • OverdueInvoice: needs overdue_by: int, reminder_level: int
type CustomerID = UUID

class OpenInvoice:
    customer: CustomerID
    amount: Decimal
    due_on: date

class PaidInvoice:
    customer: CustomerID
    amount: Decimal
    paid_on: date

class OverdueInvoice:
    customer: CustomerID
    amount: Decimal
    overdue_by: int
    reminder_level: int

def release_the_hounds(invs: list[OverdueInvoice]) -> list[CustomerID]:
    # ... (Simpsons "Release the Hounds" meme shown)

Especially when dealing with overdue invoices and deciding whether to start debt collection on real people, the focus shifts completely. You're still looking at the same data from the same source, but you probably need to enrich it from other sources, compose ORM models, or call third-party APIs.

Since this code can have devastating consequences, I want my business logic straightforward and data structures clear. I certainly don't want one generic big blob with lots of magic and optional fields. Having classes with many | None type annotations is a big design smell, and one of the gifts typing gave us is spotting this.

Some packages offer ways to make this less severe (tagged unions, table inheritance, field aliases), but they're underused, advanced features and often fail on the last mile when things get really complicated—precisely where they'd be most important.

How do people usually deal with this conflict?

  • Approach 1 (bad): ORM xor Web = primary
    Depending on preference, you pick the web API or database schema (never the domain model!) as your primary model and let the rest conform. This is "database-driven 'design'" (meant as a pejorative, not a best practice). You're starting on the back foot, implementing business logic on raw data. (Yes, Banner from "1923" was a DBA before he saw your database schema.)
  • Approach 2 (worse): Pydantic + SQLAlchemy → 😥 domain model
    You balance the needs of the web API schema with the database schema, maybe using the same models for both (less typing!). You get mediocre results because these layers have conflicting goals. Then you force this full compromise onto your domain model. It's impeded by two impedance mismatches. This is why I find the combination of ORMs and class-based validation so unholy. The domain model, foundational to your business logic quality, is squeezed. This "unified data model" dream is more of a nightmare. If the shape of data shapes code, weirdly shaped data shapes your code in weird ways. It can't be anything but weird, dictated by a compromise between database and web API schemas with conflicting goals. This isn't the fault of the tools; it's a systemic problem of forcing a unified model on layers with conflicting goals.

But wait, it gets worse.

Problem #2: Both calcify (eventually)

In theory, API versioning lets us change web API details anytime. In theory, database migrations let us change schemas anytime.
In practice, they calcify the shape of already imperfect data.

  • Changing web APIs angers users (nobody has time to adjust API calls).
  • Database migrations are tedious and problematic, especially under load, no matter how good the tools.

My experience is that circumstances lead to a loss of control over database tables eventually (if you ever had it). Greenfield projects are very rare (one of IT's dirtiest secrets). This means you almost never get to design those models.

If you let all this happen:
You have lost control over your domain model and therefore over your business logic.
Since the shape of data shapes code, you've lost control over your business logic, which is bad.

Adam Montgomery wrote in "How I Build":

"It's ok to have duplicative-looking types for different layers of a project—e.g. ORM, API schema, and business logic. Types need to be easy to change and reason about so we can adapt them to our evolving requirements. Types near the edge (like API schemas and database tables) are inherently less flexible. It's often better to explicitly map types between these layers rather than create a "franken-type" and allow the least flexible layer to calcify the rest."

This is my talk in one slide. "Map types" just means convert to better types. You should be okay having three classes for the same thing, depending on what you're doing with it. It's okay to have even more (like my invoice example).

Now, for an example:

"Good examples are by far the hardest part of explaining design"
— Kent Beck

We can't just take something from production (copyright, getting fired, context). Let's use international postal addresses.
Some countries (like Ireland, since 2015, the last OECD country to adopt them) use a postal code as enough to receive mail. Some countries don't have postal codes. I'm sure some in Ireland refuse to use them out of principle.

So, every field of an address is optional (except country):

1
2
3
4
5
6
7
class PostalAddress:
    addr1: str | None
    addr2: str | None
    zip: str | None
    state: str | None
    city: str | None
    country: str

This is not a domain model; this is a data model. Add an ID, and it's a database table. This isn't useful for business logic or validation. You could use a dictionary and lose nothing. Imagine adding behavior to this class—a quagmire of if is None or match statements, or dispatch on country, fighting the type checker.

Assuming your code needs the data shape to matter (e.g., formatting addresses for envelopes—not business code, but it matters there), you can hide valid shapes in a Rube Goldberg machine of if None and match statements, or make the shape explicit:

class USAddress:
    addr1: str
    addr2: str | None # Only addr2 is optional
    zip: str
    state: str
    city: str
    country: str

def format_us_address(
    addr1: str,
    addr2: str | None,
    zip: str,
    state: str,
    city: str,
    country: str
) -> str:
    ...

Looking at USAddress, you know what fields exist. A function taking it knows what it's getting, defined in code, validated by MyPy/Typer. You can use all fields without checking (except addr2). It's black and white.

There are ways to get this out of a Pydantic model. You just have to know you want that. It has to be a goal. You can pass six arguments to a function with correct type annotations. You fight the type checker once on invocation, but inside your important code, it's your ally. There are many edges in your code.

What I'm suggesting:
Not to ditch all your tools, but in an ideal virtual case:
Start with the domain model.

Then, it depends, but start with the domain model, then start ~engineering~.

Accept the conflicting goals of all layers and adapt data to its optimal shape for each case, understanding there might be more than three shapes. For a database, this could mean using the repository pattern, using ORM convenience for queries but spitting out neat, clean data classes (like Address classes) with encoded state, nice types, and few optional fields, decoupled from the ORM and any IO, making them easier to test (Design Pressure #1).

We don't live in an ideal world. This is software engineering; it means "it depends." We're talking trade-offs. Many use cases would be 90% data conversions, not ideal. But you definitely need to start with the domain model to understand your problem domain (the harbor example) and what your software should do, using what data, in what ideal shape for that problem. If you compromise, you must know what you're compromising on.

When you know what you'd like, then you compromise, aware of the design pressure of passing a Pydantic/ORM object into your business code. Is convenience worth it? Often, yes. Sometimes, no. Compare performance of mapping data vs. algorithms on suboptimal structures. Look if your ORM/validation tools can shape objects closer to your ideal. But first, know your ideal.

Do it conscientiously, know your options, so you don't call the result:
NO FATE (as carved by Sarah Connor).

Unfortunately, a heavy influence I see is pure laziness. Laziness leads to papering over impedance mismatches with accidental complexity (complexity not inherent to the problem). Laziness can be great (automating repetitive tasks), but this is the wrong type: shortcuts to avoid representing entities in multiple shapes, avoiding pressing more keys. We pay later with more code paths to test/consider (every if doubles them), harder-to-understand code, and awkward data structures where it matters, or business code coupled to cache implementations.

Proper design becomes a delayed gratification, which humans are famously bad at.

Takeaways

  1. Your business code is sacred. If you haven't thought about it, it's probably buried. Find it. Make data structures serve it. Try domain modeling (maybe with Sans-I/O) in a toy project.
  2. Protect it from your tools. Keep your tools, but decouple business logic. Learn them properly. Find ways to limit their design pressure on your code.
  3. Write tests; get a better design. Stop monkey patching (Brandon Rhodes: "software bankruptcy"). Let pain guide you to less coupling.

That's all. If you want to go deeper: OX.CX/DESIGN (QR code shown) has articles, books, videos, and these slides. It's a wide field.
(If you got the Bismarck joke and speak German, get domains/hosting from VRMD.DE. They paid my way and let me do things properly.)

Follow me on socials (HYNEK.ME, YouTube: YOUTUBE.COM/@THE_HYNEK). I'm slow at publishing (the George R.R. Martin of YouTube, twice today!), but people like it. I'm only ~7 million subscribers from T-series great, so I need all of you!

Thank you very much.

Edit Report
Pub: 26 May 2025 02:06 UTC
Views: 405