Nathan
← Back to articles

QR Tool CLI - Hexagonal Architecture

pythonsoftware-architecturehexagonal-architectureCLIopen-sourcelearn-in-public

I Built a QR Code CLI in Python — Here's Why the Architecture Matters More Than the Feature

apollo-photography-jukKJSr9FcA-unsplash.jpg

There's a certain kind of developer itch that comes from using a tool you don't trust. For me, it was QR code generators.

Every time I needed one, I'd open a browser, paste my data into a website I barely remembered the name of, and click generate. Sometimes it was a Wi-Fi password. Sometimes a URL for testing on a phone. Once, embarrassingly, it was an API key — and that's when I decided to stop.

The result is qr_generator, a CLI tool I built in Python that lives on my machine and goes nowhere. But the feature set isn't really the point of this post. The point is what I learned building it around a specific architectural pattern: Hexagonal Architecture.

Why Hexagonal Architecture?

Hexagonal Architecture — sometimes called Ports and Adapters — is built around one core idea: your application's business logic should be completely isolated from everything that might change. The transport layer (CLI, HTTP, gRPC), the infrastructure (third-party libraries, databases, file systems) — none of that should bleed into the domain.

Your core defines what it needs through interfaces (called Ports), and the outside world provides implementations (called Adapters) that conform to those contracts. The dependency arrow always points inward, never outward.

I'd read about this pattern. I'd looked at diagrams. But I hadn't really felt it until I built something with it.

How It Maps to This Project

The tool has three layers:

Core Domain — This is where the encoding and decoding logic lives. It defines two Protocols: one for encoding, one for decoding. It knows nothing about click, nothing about PNG files, and nothing about pyzbar. It operates entirely on plain Python types. A unit test for this layer runs in about 0.01 seconds because there's no I/O to wait on and no files to create.

Infrastructure (Adapters) — This is where the third-party libraries live. The encoder adapter wraps segno. The decoder adapter wraps pyzbar and Pillow. Both adapters conform to the Protocols defined by the core. If I wanted to swap segno for a different QR library tomorrow, I'd write a new adapter and change one line of wiring. The core wouldn't know anything changed.

One detail worth highlighting: the decoder adapter uses a heuristic — measuring the ratio of printable characters against string.printable — to determine whether a decoded payload is UTF-8 text or raw binary data. That decision lives in the adapter, not the core. The core just receives a typed result.

Delivery (CLI) — The terminal interface, built with click. This layer is responsible for reading from stdin, resolving file paths, formatting colored terminal output, and nothing else. It delegates all real work to the Core via the Engine.

CLI → Core Engine → Protocols ← Adapters

The Anti-Corruption Layer in Practice

One of the most practical benefits of this structure is error handling.

When pyzbar fails — and it does, with UnicodeDecodeError or ValueError depending on the input — those errors are caught inside the adapter. They get translated into QRToolError, a domain exception defined by the core. The CLI receives a clean, predictable error type and renders an appropriate message.

Without this, library-specific exception types would leak into the delivery layer. The CLI would need to know about pyzbar internals to handle errors properly. The layers would be tangled. Changes to the library could break error handling in the CLI.

The adapter acts as a translation boundary in both directions: input going in, exceptions coming out.

What the Tool Actually Does

Since this is also a practical tool and not just an architecture exercise:

# Render a QR code directly in the terminal
qr_generator encode --payload "https://github.com/nathan-trann"

# Save as PNG or SVG
qr_generator encode --payload "WIFI:S:MyNetwork;T:WPA;P:Secret;;" --output wifi.png

# Encode a binary file
qr_generator encode --file firmware_config.bin --output config.png

# Pipe from stdin
cat id_rsa.pub | qr_generator encode --output key.png

# Decode an existing QR image
qr_generator decode wifi.png

It follows the Unix philosophy. Data flows through stdin and stdout. The output format is inferred from the file extension. If no output file is specified, the QR code renders directly in the terminal using Unicode block characters.

What I Actually Learned

A few things stood out that I hadn't fully appreciated from reading alone:

Protocols before implementation. Defining the interfaces first — before writing a single line of adapter code — forces you to think about what the core actually needs. Not what the library provides. What you need. This is a subtle but meaningful shift in how you approach design.

Testing becomes genuinely fast and clean. The core tests use lightweight fake adapters. They don't generate any files. They don't call any libraries. They test orchestration logic in isolation. The E2E tests use click's CliRunner to simulate a real terminal environment. The layers test different things, and neither interferes with the other.

Small projects reveal design trade-offs clearly. A large codebase has so many moving parts that it can be hard to see the architecture. A small, focused tool like this surfaces the decisions cleanly. Every line of code is there for a reason, and when something feels wrong structurally, there's nowhere to hide it.

Worth Building Your Own

If you're trying to learn an architectural pattern, I'd recommend building a real tool around it rather than a contrived example. The tool doesn't have to be large. It just has to be something you'll actually use, so that the design decisions have weight.

You'll feel the difference between a design that works and one that just looks clean on a diagram. And that feeling is hard to get any other way.

The full source is on GitHub if you want to look at how the layers are structured: https://github.com/nathan-trann/qr-generator