Skip to main content

Todo CLI in Rust 1. Hexagonal architecture in a small project

·2 mins
Rafael Fernandez
Author
Rafael Fernandez
Mathematics, programming, and life stuff
Todo CLI in Rust without fluff - This article is part of a series.
Part 1: This Article

Hey folks.

Read this chapter in Spanish: ES

This series tells the story of how we built todo-cli-rs from CodeCrafters Project #1 (Rust Projects). If you want to follow code while reading, here is the repository:

The challenge looked simple (until it wasn’t)
#

The initial scope was classic:

  • add
  • list
  • done
  • delete
  • local persistence

At this point, the quick path is obvious: throw everything into main.rs, parse args, read/write JSON, print output, done.

That works until the first meaningful change request lands.

The decision that changed everything
#

Before implementing use cases, we set boundaries:

  • Hexagonal Architecture to isolate business rules from infrastructure.
  • Screaming Architecture so the folder structure says “tasks”, not “misc utils”.

References:

Why this instead of a flat design
#

Flat approach:

  • CLI parses,
  • validates,
  • persists,
  • and formats output.

It is fast to start, expensive to evolve.

Real example: add a second persistence backend later.

  • In a flat design, CLI, business logic, and tests are tangled.
  • In a hexagonal design, adapters change while domain and use cases stay stable.

The first contract we made explicit
#

We defined the command contract early:

  • add <title>
  • list [--status <all|todo|done>]
  • done <id>
  • todo <id>
  • delete <id>
  • --output table|json

Implemented in:

This is not just UX polish. Input contract clarity prevents ambiguity across the whole system.

Layer map and responsibilities
#

  • domain: pure business rules.
  • application/use_cases: orchestration.
  • ports: contracts so application does not know infrastructure details.
  • adapters/cli: terminal input/output.
  • adapters/persistence: concrete repositories.

Module entry:

Where this paid off immediately
#

When we introduced dual output (table/json) and file persistence, use cases stayed untouched. No serde_json, no path management, no printing logic leaked into core flow.

That is exactly what good boundaries are supposed to do.

Closing
#

Part 1 is about rules of the game: we did not optimize for “it runs once”, we optimized for “it survives change”.

In the next chapter, we go into the core: immutable domain transitions and typed error boundaries by layer.

Todo CLI in Rust without fluff - This article is part of a series.
Part 1: This Article