In the Todo CLI series, I spent a full chapter designing a single trait: TaskRepository. Four methods. No implementation detail. At the time, I framed it as architecture work: define a contract that separates what the application needs from how the infrastructure happens to provide it.

That was true, but it was not the whole story.
Looking back, every choice in those four signatures was also a choice about what can be expressed at the boundary between application and persistence. And once I saw it that way, the connection became hard to unsee: that is exactly what a grammar does.
Before getting there, it helps to make the contract-implementation split explicit.
Contract versus implementation #
The contract is the trait. It says what the application needs: four operations with precise types, without mentioning JSON, files, paths, or serde. It is the shape of the dependency in domain terms.
The implementation is the adapter. It decides how to satisfy that contract with a specific technology. In the Todo CLI project there are two:
InMemoryTaskRepository, aHashMap<Uuid, Task>in memory. Fast, deterministic, and side-effect free. Great for tests.JsonFileTaskRepository, which reads and writes JSON on disk usingserde_jsonand thedirectoriescrate for platform-aware paths. This is the production adapter.
Both implement the same trait. The application layer does not know, and does not need to know, which one it is talking to.
This separation buys you three very practical things:
- Interchangeability. You can swap JSON for SQLite, a remote API, or something else later without touching business logic. You just add a new
impl TaskRepository. - Testability. Tests use the in-memory adapter: fast, reproducible, and with no filesystem cleanup. Production uses the JSON adapter. The application code stays the same.
- Bounded errors. The contract defines
RepoResultas the error type at the boundary. Each adapter translates its internal failures, like I/O, serialization, or permissions, into that type. The error chain stays clean:RepoError->ApplicationError::Repository->CliError::Application, with nounwrap()orpanic!().
I still like that architectural framing. But lately I have found the next step even more interesting.
The signatures as production rules #
Look at the trait again:
pub trait TaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()>;
fn list(&self, query: TaskQuery) -> RepoResult<Vec<Task>>;
fn find_by_id(&self, id: Uuid) -> RepoResult<Option<Task>>;
fn delete(&mut self, id: Uuid) -> RepoResult<bool>;
}Four methods. Four shapes that a conversation between the application and persistence can take. If you squint a little, this is a grammar. Not a grammar for strings, but a grammar for interactions. The methods are the production rules. The types in the signatures are the terminals and nonterminals. Together, they define the vocabulary of what can be said across this boundary.
When I chose Option<Task> for find_by_id instead of Result<Task, NotFoundError>, I was making a grammatical choice: “not found” is not an error sentence in this language, it is a valid word. When I chose bool for delete instead of Option<Task>, I decided that deletion should answer with a yes-or-no signal, not an echo of the removed value. Those are not implementation details. They are part of the syntax of the contract.
The gap we already knew was there #
In that same chapter, I defined RepoError:
#[derive(Debug, Error)]
pub enum RepoError {
#[error("Repository internal error: {error}")]
InternalError { error: String },
}I called it out at the time: one variant, with a free String payload. Intentionally generic. Intentionally lossy. Good enough for the scope of the project, but still a conscious trade-off.
From the grammar point of view, that String is where the cracks start to show. RepoError::InternalError { error: String } is syntactically valid for any string. An I/O failure, a serialization error, a permissions issue, or "colorless green ideas sleep furiously". The grammar accepts all of them. It cannot tell a useful diagnostic message from nonsense.
That is the same gap you see in something like OrderStatus::Shipped { tracking: String::new() }. The type system says the shape is valid. The domain says the content is nonsense. The grammar controls structure, not meaning.
Implementations as semantics #
If the trait is the grammar, the implementations are the semantics. They assign meaning to the shapes the grammar allows. A grammar gives you valid sentences. A semantics tells you what those sentences do.
Take save. The trait says: “this operation takes a Task by value and returns RepoResult<()>.” That is the syntactic shape. It says nothing about what actually happens to the task. It might be written to disk, kept in memory, sent over the network, or silently discarded. The grammar allows all of those. The impl is what chooses.
InMemoryTaskRepository gives save one meaning: insert the task into a HashMap keyed by ID. If the ID already exists, overwrite it. No I/O, no serialization. The effect is immediate, and the state lives only as long as the process.
// Semantics A: in-memory insertion
impl TaskRepository for InMemoryTaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()> {
self.cache.insert(task.task_id(), task);
Ok(())
}
}JsonFileTaskRepository gives save a different meaning: read the current JSON file from disk, deserialize it into a Vec<Task>, insert or replace the task by position, serialize the full vector again, and write it back atomically. Same signature, save(&mut self, task: Task) -> RepoResult<()>, very different operational meaning.
// Semantics B: read-modify-write cycle on disk
impl TaskRepository for JsonFileTaskRepository {
fn save(&mut self, task: Task) -> RepoResult<()> {
let mut tasks_file = self.read_task_file()?;
if let Some(index) = tasks_file
.tasks
.iter()
.position(|stored| stored.task_id() == task.task_id())
{
tasks_file.tasks[index] = task;
} else {
tasks_file.tasks.push(task);
}
self.write_tasks_file(&tasks_file)
}
}Same sentence, different interpretation. That is the syntax-semantics relationship in a nutshell. The grammar generates save(task). The in-memory implementation interprets it as a HashMap insertion. The JSON implementation interprets it as a read-modify-write cycle on disk. Both satisfy the trait. Both are valid under the grammar. But they clearly do not mean the same thing.
This is why I think the separation matters for more than architectural neatness. When the trait is the grammar and the impl is the semantics, you can change the meaning without changing the language. You can move from in-memory to JSON to SQLite, and the application still speaks the same sentences: save, list, find_by_id, delete.
And this is also the bridge to the Syntax and Semantics series: once you start seeing types as syntax and implementations as interpretation, the jump from a Rust repository port to formal language ideas is not that big.
Traits as boundary grammars #
This is the connection I wanted to make explicit. A trait in Rust is not only an interface in the OO sense. It is also a grammar for a module boundary. It defines:
- What operations are expressible, through the methods.
- What inputs each operation accepts, through the parameter types.
- What outputs each operation can produce, through the return types.
- What effects are visible, through the
&selfversus&mut selfdistinction.
And like any grammar, it has limits. It defines what shapes are possible, not what those shapes mean. A trait cannot enforce that save is called before delete. It cannot enforce that list returns results consistent with previous save calls. It cannot enforce that the String inside RepoError carries useful diagnostic information. Those are semantic constraints. The trait gives you syntax, not meaning.
The best trait designs try to push more correctness into the grammar. Choosing Option<Task> over Result<Task, NotFoundError> is not cosmetic. It removes a class of meaningless error states from the language. Choosing TaskQuery instead of loose booleans narrows the space of valid inputs. Each choice helps shrink the gap between what can be said and what should be said.
But the gap never disappears completely, and that is fine. In practice, I think it is better to name that gap honestly, whether as technical debt or as a deliberate design boundary, than to pretend the trait solves more than it does.
A thread worth pulling #
If this way of looking at the problem clicks for you, the next stop is the Syntax and Semantics series. That series starts with Chomsky’s hierarchy, maps BNF production rules to Rust enums and Scala sealed traits, and keeps following the same tension: the distance between what is syntactically valid and what is semantically meaningful.
The funny part is that this whole post came from going back to a trait I had already written and realizing it was saying more than I thought.
At first I thought I was just drawing a clean architectural boundary. A few weeks later, it was obvious I had also been defining a tiny language, with its own grammar, its own limits, and its own room for nonsense.
And honestly, that is probably one of the reasons I keep coming back to Rust. It has a way of turning small design decisions into bigger ideas if you stay with them long enough.