Skip to main content
  1. Posts/

Todo CLI in Rust 2. Immutable domain and typed errors by layer

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

We continue with the series.

With the architecture already defined and boundaries drawn, it was time to get our hands dirty and build the most critical part of our system: the Domain.

The main goal of this layer is to model the business in such a way that the rules are evident, strict, and, above all, don’t depend on the programmer “remembering” to validate them in the rest of the code. If the domain allows it, it’s valid; if not, it shouldn’t even compile (or it should fail with an explicit error).

todo-cli-in-rust-2-immutable-domain-and-typed-errors-by-layer-img-28.png

Reference code:

Task Entity: invariant-oriented design
#

The domain is the heart of our hexagonal architecture. And within this heart, the Task entity is the fundamental piece. What good is a task management application if we don’t have a robust and crystal-clear definition of what exactly a task is?

In our context, a task is not just a flat data structure or a record in a database. It is an element with a lifecycle. It has descriptive attributes (title, description), state (Todo, Done), and audit information (created_at, modified_at). All under a unique identifier.

The base definition of Task looks like this:

  • id: Unique identifier (we used the uuid crate to generate v4 UUIDs).
  • title: The name of the task (String).
  • status: A strongly typed enum (TaskStatus) that restricts possible states (Todo, Done).
  • created_at / modified_at: Audit dates handled with the chrono crate (DateTime<Utc>).

For this entity to be able to travel to or from the persistence and presentation layers without manual effort, we rely on the omnipresent serde crate by deriving Serialize and Deserialize.

So far, we aren’t reinventing the wheel. What’s truly relevant begins when we decide how that task mutates over time. Our application is going to perform actions that will change the entity’s state, and it is precisely here where we define the interaction flow that governs the business rules.

Immutable transitions (self -> Result<Self>)
#

The obvious actions we can perform on a task are: completing it, reopening it, or editing its title. In many systems, these actions would be modeled simply by altering the corresponding field, that is, mutating the instance directly in memory.

However, in this project we chose to make state changes through immutable functions that consume the current task and return a modified copy (or an error):

pub fn mark_done(self) -> DomainResult<Self>
pub fn mark_todo(self) -> DomainResult<Self>
pub fn edit_title(self, title: String) -> DomainResult<Self>

Why immutable self instead of &mut self?
#

If we use mutable references (&mut self), we open the door to a dangerous code drift. When you allow any part of the system to freely mutate the entity, the following happens:

  • A use case (UseCaseA) changes the state status = Done but forgets to update modified_at.
  • Another use case (UseCaseB) changes the title, and also performs its own validations directly in the application layer.
  • Over time, loose setters appear like pub fn set_status(&mut self, status: Status) that bypass any business validation.

Result: Business rules end up scattered throughout the application, and behavior becomes inconsistent.

By demanding self (which consumes the object by value in Rust) and returning a Result<Self>, we achieve several things:

  1. We prevent side effects: A task doesn’t mutate transparently under the table. You either have the original task, or you have the new version.
  2. We force validation (Gatekeeping): Every modification must pass through a single function that acts as a checkpoint. If the transition makes no sense (e.g., editing the title with empty text), the function returns an explicit error.
  3. Rust helps us: By consuming self, the compiler prevents you from accidentally using the “old version” of the task in subsequent lines, forcing you to reassign it.

Concrete example: validating compound rules
#

The real value of encapsulating this in the domain shines when business rules stop being simple setters. It is not enough to say “the state is Done”, you have to define the restrictions linked to the object’s natural behavior.

Imagine the internal logic of transitioning a task. When we call mark_as(status) several things happen simultaneously in a single point of truth:

  1. Prevents useless transitions: Verifies if the new state is the same as the current one (Todo -> Todo, Done -> Done). If so, we do nothing and return a domain error indicating that the transition is invalid.
  2. Linked update: If and only if the state changes, we generate a new timestamp for modified_at using Utc::now().
  3. Preservation (Struct Update Syntax): In Rust, it is very idiomatic to create a new instance copying the unmodified fields of the old one using ..self. This way we clone the original created_at and id intact and clean:
fn mark_as(self, status: TaskStatus) -> DomainResult<Self> {
    if status == self.status {
        Err(DomainError::InvalidStatusTransition { /* ... */ })
    } else {
        Ok(Self {
            status,
            modified_at: Utc::now(),
            ..self // Mágico: copia el id, title y created_at de la tarea original
        })
    }
}

By encapsulating this, we avoid subtle but annoying bugs, like the classic audit problem where the “last modified” of a record has changed in the database without any useful data actually being touched. Everything happens within the safe ring of the domain.

Taxonomy of errors: A type for each layer
#

In Rust, error handling via Result<T, E> is exceptional. But that tool loses all its power if we abuse the infamous Result<T, String> or use a single GlobalError for the entire project.

Knowing what failed is vital, but knowing where and why it failed is what separates a good design from a painful one to debug. For this reason, we have created a strict error taxonomy, relying heavily on the great thiserror crate. With it, we easily derive std::error::Error and format the messages automatically (#[error("task title cannot be empty")]), giving unique semantics to each node of the architecture.

Errors mapped by responsibility:

  • Domain: DomainError -> Ex: “Empty title”, “Invalid state transition”. They are pure business errors.
  • Repository: RepoError -> Ex: “Could not read JSON file”, “DB connection error”. They are technical infrastructure errors.
  • Application: ApplicationError -> Ex: “Task not found in repository”, “Failed to execute flow X”. It groups and orchestrates errors from lower layers, adding functional context to them.
  • CLI: CliError -> Ex: “Invalid argument format”. It’s the last link, designed to give a clear (and pretty) message to the user in the terminal.

Why so many error types?
#

Imagine a real case: The user executes done 123 and the ID doesn’t exist.

If everything ends up encapsulated in a “Processing error” message, you have lost critical information. Did the command parsing fail? Did the disk explode when opening the JSON? Or was the ID simply not there?

  • The ID not existing in the DB is not a technical or infrastructure failure; it is an expected error of the functional flow (a TaskNotFound from the application layer).
  • The user trying to mark as completed a task that is already completed is a violation of the rules (a DomainError).
  • Not being able to access the persistence file is a system error (RepoError).

By typing each error by layer, a use case can catch a RepoError, wrap it in an ApplicationError, and pass it to the CLI adapter so it can decide whether to show the error in red, log it to the system, or exit with code 1. All of this makes testing specific failures (e.g., assert_eq!(err, ApplicationError::TaskNotFound)) trivial and predictable.

Use cases: Boring orchestrators
#

Once we have a robust domain that protects itself, and a clear error system, what is left for the Application layer (Use Cases) to do? Mainly: be boring.

Use cases should be simple orchestrators. They relate domain entities to outside resources (repository ports), but never make business decisions or mutate things “by hand”. To further isolate the input, we apply the Command Pattern: the use case does not receive loose variables (id, status), but rather receives an immutable struct (MarkTaskDoneCommand), which gives us much more cleanliness if in the future the action requires more parameters.

A typical use case of our CLI was reduced to a linear and predictable flow: receive Command -> search in repo -> transition entity -> save to repo.

You can see a couple of examples here:

If you notice, the mark_task_done use case doesn’t verify whether the task was already done or not. It simply injects the dependencies using a TaskRepository Trait and invokes task.mark_done(). It’s the domain that decides if the action is valid, and if it fails, the use case cleanly propagates the DomainError upwards using the ? operator (Ok(task.mark_done()?)).

The definitive sign of maturity in the design
#

The acid test for this immutable design and isolated errors occurred when we started plugging in different pieces. When we went from using simple text outputs to returning JSON objects through the terminal, and when we connected real persistence to disk… we didn’t have to touch a single line of the state rules in Task.

That is exactly what you look for in Hexagonal Architecture. It indicates that your domain is free from external distractions and that dependency inversion is doing its job.

Conclusion: The value of discipline
#

Designing the domain is not simply grouping data into structs. It is coding the unbreakable laws of our system.

It might seem tempting (and faster) to expose all fields as pub and mutate them from main.rs, but that path always exacts a high toll in technical debt. By choosing the “long” path, immutable entities, explicit transitions, and typed errors by layer, we have gained something invaluable: peace of mind.

Now we know it is impossible to corrupt the state of a task from the UI. We know that if something fails, we will have a precise error indicating whether it was the fault of the user, the system, or the logic. We have built an armored core.

With this heart beating and protected, we are now ready to give it a home where tasks can be saved between executions. In the next post, we will open the pantry: we will see how to design the repository contract, implement JSON persistence to disk, build an in-memory adapter for tests, and make decisions that prepare the ground for scaling.

See you in the persistence layer!

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