cd /news/developer-tools/how-reading-a-design-patterns-book-l… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-69392] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

How Reading a Design Patterns Book Led Me to Audit My Rust Framework with UML and Merise

A developer audited their Rust web framework Runique by modeling it in UML and Merise, finding over 20 latent bugs including three security-relevant ones that had not been caught by tests or the compiler. The method translates Rust type constructs into diagram notations and audits discrepancies between the two representations.

read13 min views1 publishedJul 22, 2026

I was reading a design patterns book. I got to the UML class diagrams β€” and I stopped.

Because the diagram on the page was describing something Rust already says in its own syntax. A struct

is a class. Option<T>

is a 0..1

association. Vec<T>

is 1..N

. Ownership is composition. The formalism I was looking at, drawn as boxes and diamonds, was the same information the compiler already had β€” just in a different notation.

Which led to an uncomfortable thought. If the type system already encodes what UML draws, then translating my code into UML shouldn't tell me anything new. It should be redundant. Unless the translation didn't line up β€” and everywhere it failed to line up, there would be a bug hiding in the gap.

So I tried it. I took Runique β€” my Rust web framework, v2.1.21 β€” and I modeled the framework itself, module by module, in UML for structure and Merise for the data. The intuition was mine; I used Claude to help verify each translation against the actual code, line by line. We found twenty-plus latent bugs. Three of them were security-relevant. None of them had shown up in cargo test

, in tracing

, or in the compiler.

This is what the gaps looked like.

Runique is a Django-inspired framework: Axum, SeaORM, Tera, edition 2024. It works. It's powered a real restaurant's booking site and runique.io itself in production for months β€” that's my dogfooding. But the audit I'm describing was on the framework, not on those sites. And that's exactly the problem β€” "it works" is the state in which bugs are hardest to see. The compiler is happy. The tests are green. tracing

shows nothing because nothing is throwing.

The tools I reach for every day are all local. cargo check

sees one function. A unit test sees one path. tracing

sees one request. None of them step back and show you the shape of the whole thing at once β€” and some bugs only exist in the shape. A field that's pub

when it should be pub(crate)

. A file written one step too early in a sequence. A type that diverges from the type it's supposed to match, three files away, behind a feature flag.

I needed an outside view of the code β€” without leaving the code. Not a rewrite, not a new tool, not a linter. A different representation of the same thing, precise enough that the discrepancies would be mechanical to spot.

Here's the idea the book handed me, made explicit.

Rust's type system, UML class diagrams, and Merise data models are three notations for the same underlying facts: entities, their relationships, their cardinalities, their invariants. The GoF patterns are just the recurring shapes that show up across all three. Rust encodes them in the type system, where the compiler enforces them. UML and Merise encode them in a separate graphical formalism, where a human reads them. The translation between the two is almost mechanical:

Concept Rust UML / Merise
Entity struct
Class / Entity
Mandatory 1..1 field by value: T
cardinality 1..1
Optional 0..1 Option<T>
cardinality 0..1
1..N
Vec<T> / HashMap<K,V>
cardinality 1..N
Composition (owned)
struct A { b: B } or Box<B>
filled diamond
Aggregation (shared)
&B / Arc<B> / Rc<B>
hollow diamond
Refinement AST β†’ HIR β†’ MIR MCD β†’ MLD β†’ MPD

One caveat, because Rustaceans will (rightly) pounce otherwise: the ownership-to-composition mapping isn't perfect. Box<B>

still owns, so it's composition, not aggregation; aggregation β€” a shared reference with an independent lifetime β€” is &B

, Arc<B>

, Rc<B>

. The analogy is a lens, not a law. But it's a sharp enough lens that when the code and the diagram disagree, the disagreement is worth investigating.

That's the whole method in one sentence: model the code in a formalism the type system doesn't natively speak, and audit the places where the two representations refuse to agree.

Merise deserves a note here, because it's the unfamiliar one for most English-speaking readers. It's a French systems-modeling method from the 1970s, still taught in French CS programs today. It splits a data model into conceptual (MCD), logical (MLD), and physical (MPD) levels β€” and crucially, at the logical level it forces you to write down both the cardinality and the concrete type of every association. That second demand is what caught one of the bugs below.

I didn't spot-check. The point of an audit is coverage, so I went module by module across the whole framework, with three lenses, each matched to what it's good at:

eihwaz_*

framework table, with cardinalities, foreign keys, and physical types.Everything is Mermaid in Markdown, versioned next to the code. No binary tool, no separate app β€” the diagrams render on GitHub and travel with the repo. Each one ends with an "anomalies" section, consolidated into a single anomalies.md

with severity and a file:line

for every finding.

Now the interesting part.

pub

field that quietly bypassed CSRF I'll open with the most interesting one β€” not the most severe, the most interesting, because it's the bug that best shows what this whole exercise is really about.

I was drawing the UML class diagram for Prisme

, the type that holds a request's parsed-and-CSRF-checked body. The diagram made me write down, for each field, its visibility. And there it was:

Prisme
  + data: StrMap        ← public
  + csrf_valid: bool
  + checked_data() -> Option<&StrMap>   ← fail-closed accessor

data

was pub

. checked_data()

β€” the accessor that returns the body only if CSRF passed β€” was right next to it. The moment those two sat in the same box on the diagram, the problem was obvious: why build a fail-closed gate and then leave the raw field public next to it?

The CSRF enforcement in Runique doesn't live in the middleware for HTML forms β€” it lives at the point of access. req.form()

checks csrf_valid

and refuses to hand you validated data if the token is bad. But if Prisme::data

is pub

, any handler β€” or, more to the point, any third-party code built on Runique β€” can read the raw body directly and skip the gate entirely, without ever knowing it did.

I want to be exact about the severity, because the honest version is more interesting than the scary one. My own code was clean: admin, login, and form()

all check CSRF before touching the body. This was never an exploited hole in Runique itself. It was a footgun in the public API β€” a shape that let someone building on the framework bypass CSRF by accident. That's a framework-author's bug, not a break-in.

The fix is one keyword, and it's the whole point of the article:

pub struct Prisme {
    /// Parsed body/query data. **Crate-private**: user code cannot read the raw
    /// body without going through the CSRF gate (anomaly C2). External access only
    /// via checked_data() (fail-closed) or req.form().
    pub(crate) data: StrMap,
    pub csrf_valid: bool,
}

impl Prisme {
    /// Fail-closed accessor: returns body data only if CSRF is valid.
    pub fn checked_data(&self) -> Option<&StrMap> {
        if self.csrf_valid { Some(&self.data) } else { None }
    }
}

pub

β†’ pub(crate)

. The Rust visibility system now structurally guarantees that the only doors to the request body are the two that check CSRF first. It's not a convention, not a lint, not a code-review rule someone has to remember. The compiler will reject the bypass. Visibility as a security mechanism β€” that's what the UML diagram surfaced, because a class diagram forces you to write down the one property (pub

vs pub(crate)

) that the code lets you skim past.

The second lens is the one that shows time. Here's the sequence diagram for a multipart form POST, as I drew it from the real pipeline:

sequenceDiagram
    participant C as Client
    participant MW as csrf_middleware
    participant P as prisme_pipeline
    participant AG as parse_multipart
    participant H as Handler

    C->>MW: POST multipart (csrf_token as a field, no header)
    Note over MW: mutating method, no X-CSRF-Token,<br/>form content-type β†’ LET THROUGH<br/>("Prisme will validate")
    MW->>P: next.run()
    P->>AG: parse_multipart(req)
    AG->>AG: stream file β†’ tmp (size cap, NO extension check)
    AG->>AG: rename tmp β†’ MEDIA_ROOT/uuid.ext (COMMIT)
    Note over AG: file committed to its final,<br/>servable location BEFORE CSRF,<br/>validation, and the handler
    AG-->>P: final paths
    P->>P: check_csrf() β†’ csrf_valid (flag only)
    P-->>H: Request
    H->>H: if form.is_valid() { ... } else { reject }
    Note over H: file already on disk in MEDIA_ROOT,<br/>never removed on rejection

Read top to bottom and the bug reads itself. The file gets renamed into MEDIA_ROOT

β€” its final, potentially publicly-served location β€” before CSRF is checked, before the form validates, and before the extension filter (which lives later, in FileField::validate

) ever runs. On any public endpoint that extracts a Request

and accepts multipart, that meant:

MEDIA_ROOT

no matter what happens next..html

, .svg

, or .js

could land in a served directory. If MEDIA_ROOT

is served statically, that's a path to stored XSS.No unit test caught this because no unit test is shaped like the request lifecycle. The class diagram didn't catch it either β€” every individual method is correct in isolation. Only the temporal view, the one that lays out order, made "written before validated" visible as a single glance.

The fix moves the commit to the end. parse_multipart

now writes to a non-served staging directory, and FileField::finalize

β€” running after CSRF and validation β€” is the only thing allowed to commit:

// No commit here: files stay in staging. FileField::finalize (the only committer)
// moves them to their served destination after CSRF + validation. On rejection,
// staging is purged by sweep_stale_staging (best-effort, TTL). Errors are logged,
// never swallowed.
Ok(data)

One committer, one gate, running in the right order β€” and a TTL sweep for staging that never got promoted, with every failure logged rather than dropped on the floor.

The last one is the bug Merise's "write down the type" discipline caught, and it's a clean demonstration of why a separate formalism earns its keep.

Here's the MLD fragment β€” the logical model β€” for the junction table between users and groups:

erDiagram
    USER ||--o{ USER_GROUPE : "belongs to"
    GROUPE ||--o{ USER_GROUPE : "groups"
    USER {
        pk id "int OR bigint under big-pk"
    }
    USER_GROUPE {
        pk user_id FK "β†’ users.id"
        int groupe_id FK
    }

Merise made me write two things next to user_id

: its cardinality (a mandatory FK into users.id

) and its physical type. And users.id

has a footnote β€” it's integer

normally, but bigint

under the big-pk

feature flag. So I went to check that the FK column followed the same rule. It didn't:

// user_id was hardcoded .integer() β€” it must follow eihwaz_users.id, which becomes
// BIGINT under the big-pk feature, or the FK is a type mismatch and fails to create.
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
user_id_col.integer();   // ← always integer, even when users.id is bigint

sessions

, history

, and reset_tokens

all propagated the feature flag to their user_id

columns. This one junction table didn't. Under big-pk

, on a strict database like Postgres, the foreign key is a type mismatch and fails to create β€” a broken migration that only shows up for the subset of users who flip that flag. The fix is mechanical once you've seen it:

let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
#[cfg(feature = "big-pk")]
user_id_col.big_integer();
#[cfg(not(feature = "big-pk"))]
user_id_col.integer();
user_id_col.not_null();

The compiler could never have caught this β€” both branches compile fine; they're just describing a database that won't build. Merise caught it because it made the type an explicit thing I had to write down and compare, instead of something buried in a ColumnDef

builder call three files away from its counterpart.

Here's the twist I didn't expect. The bugs were the goal of the exercise. But they weren't the most valuable thing it produced.

The most valuable thing was the map.

By the end I had an architectural understanding of Runique that I β€” its author β€” did not have when I started. Not a vague "I know my codebase" feeling, but a precise, drawn, versioned model of every layer and every data flow. The bugs were a byproduct of building that map. The map is the asset.

Two things convinced me of that. The first was the verified false positives. I didn't just log the real bugs; I logged every hypothesis I was sure of that turned out to be wrong. I was convinced makemigrations

couldn't handle ALTER COLUMN

β€” false, it uses a full schema diff I'd forgotten I wrote. I was convinced two session-write paths produced a divergent session_id

β€” false, the on_conflict

clause makes it deterministic. Documenting the things you were wrong about is what separates an audit from a victory lap, and it's the part most people would quietly delete.

The second was the transverse themes. Once the bugs were on one page, they stopped looking like twenty separate bugs and started looking like four repeated anti-patterns: errors swallowed in silence; two sources of truth for one fact; security operations in the wrong order; a feature flag that didn't propagate everywhere it needed to. That's the jump from "fix the bug" to "extract the rule" β€” and you can only make it when you can see all the instances at once. The map is what let me see them at once.

If you want to try it, the method compresses to a few rules:

And one honest warning: this takes real time. It is not worth it on a weekend project. It's worth it on a production codebase you've started to distrust β€” the one that works, that passes its tests, and that you've nonetheless got a quiet feeling about. That feeling is usually right, and this is how you find out what it's pointing at.

The loop back to the book that started it is this. When I modeled Runique, I found patterns already in it β€” Builder, Template Method, Composite, Strategy β€” that I had never deliberately placed. They'd emerged on their own, because they're the shapes a problem like this pushes you toward. The book didn't teach me to add them. It gave me the vocabulary to see the ones that were already there, and the modeling gave me the visual proof they existed.

The full set of diagrams β€” every UML class diagram, the Merise data model, and the sequence flows, each with its own anomalies section β€” lives in the diagramme/ folder on GitHub. Runique itself is on

One question for the comments, because I genuinely want to know: have you ever used a modeling formalism to audit code you'd already written β€” and what did it reveal that the code itself was hiding?

── more in #developer-tools 4 stories Β· sorted by recency
── more on @runique 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/how-reading-a-design…] indexed:0 read:13min 2026-07-22 Β· β€”