Unifying Clean Architecture use cases and AI agent tools (in Rust)
Introduction
I like to keep the business domain as agnostic use cases (as far as possible!) and entities. This particular shape has several benefits, but the main one, in my view, is that a use case inverts the flow of control (dependency inversion): you call the use case to perform a business function, and that's it. It then calls back our implementation of its output interface to hand us the result. The idea in a nutshell:

Note: in this article I simplify a few things so as not to get in the way of understanding.
Implementing a use case in Rust
Take a bank transfer use case as an example:
/// Each account's balance, for the demo:
type Accounts = BTreeMap<String, u32>;
/// A trait shared by every use case
trait UseCase {
type Output;
fn execute<P: OutputPort<Self::Output, String> + ?Sized>(self, accounts: &mut Accounts, out: &mut P);
}
pub struct Transfer {
pub from: String,
pub to: String,
pub amount: u32,
}
impl UseCase for Transfer {
type Output = String;
fn execute<P: OutputPort<String, String> + ?Sized>(self, accounts: &mut Accounts, out: &mut P) {
match self.transfer(accounts) {
Ok(done) => out.success(done),
Err(e) => out.failure(e),
}
}
}
impl Transfer {
fn transfer(&self, accounts: &mut Accounts) -> Result<String, String> {
let from = accounts.get_mut(&self.from).ok_or(format!("no account {}", self.from))?;
if *from < self.amount { return Err(format!("{} has only {}", self.from, from)); }
*from -= self.amount;
*accounts.get_mut(&self.to).ok_or(format!("no account {}", self.to))? += self.amount;
Ok(format!("{} sent {} to {}", self.from, self.amount, self.to))
}
}
A use case is a plain Rust struct whose output port is a generic trait with two types: what the use case passes on when it succeeds, and what it passes on when it fails:
trait OutputPort<T, E> {
fn success(&mut self, value: T);
fn failure(&mut self, error: E);
}
It has a single public method to perform the action, execute, which hands the result to out through success or failure.
Calling the use case
let mut state = Accounts::default();
state.insert("Alice".into(), 100);
state.insert("Bob".into(), 0);
let mut out = FnOutputPort {
on_success: |done: String| println!("{done}"),
on_failure: |e: String| println!("refused: {e}"),
};
use_cases::Transfer { from: "Alice".into(), to: "Bob".into(), amount: 10 }.execute(&mut state, &mut out);
The Rust syntax looks convoluted, but it is actually quite simple: two lambdas are declared, and together they implement the use case's output interface to receive the result. A single implementation of FnOutputPort, reusable everywhere, is enough, with one closure per output:
struct FnOutputPort<S, F> { on_success: S, on_failure: F }
impl<T, E, S: FnMut(T), F: FnMut(E)> OutputPort<T, E> for FnOutputPort<S, F> {
fn success(&mut self, value: T) { (self.on_success)(value) }
fn failure(&mut self, error: E) { (self.on_failure)(error) }
}
Testing the use case
A test plugs in the same way, with an FnOutputPort that keeps the result so it can be checked. The use case cannot tell the difference:
let mut state = Accounts::default();
state.insert("Alice".into(), 100);
state.insert("Bob".into(), 0);
let mut out = FnOutputPort { on_success: |_: String| {}, on_failure: |e: String| panic!("{e}") };
use_cases::Transfer { from: "Alice".into(), to: "Bob".into(), amount: 30 }.execute(&mut state, &mut out);
assert_eq!(state["Alice"], 70);
assert_eq!(state["Bob"], 30);
Automatic AI agent (LLM) integration
Here is the idea: I would like my AI agent to be able to use my use cases as tools. Let me say right away that in a production version there is a facade between the "use case" tools and the AI agents, forming a hardened harness; to keep things simple, let's assume the AI agent calls the use cases directly. The use case struct is extended with annotations meant for the AI:
#[agent(name = "transfer", description = "Send money from one account to another. Refused if the payer would go below zero.")]
pub struct Transfer {
#[agent(description = "Who pays.")]
pub from: String,
#[agent(description = "Who receives.")]
pub to: String,
#[agent(description = "How much, in whole euros.")]
pub amount: u32,
}
What #[agent] generates:
{
"name": "transfer",
"description": "Send money from one account to another. Refused if the payer would go below zero.",
"parameters": {
"type": "object",
"properties": {
"from": { "type": "string", "description": "Who pays." },
"to": { "type": "string", "description": "Who receives." },
"amount": { "type": "integer", "description": "How much, in whole euros." }
},
"required": ["from", "to", "amount"]
}
}
In a complete version, you would set up a system prompt (a directory) with a short description pointing to each use case's detailed description, to keep the number of tokens down.
For example, the directory sent on every turn holds just one line per use case:
You act on bank accounts through the use cases below.
Before using one, call find_actions with its name to get its full sheet.
- transfer [write] Send money from one account to another.
- deposit [write] Add money to an account.
- get_balance [read] The balance of an account.
- close_account [write] Close an account.
The model only asks for the full sheet (the JSON above) of the use case it needs: find_actions(["transfer"]).
This directory is also generated automatically, from the annotations.
AI agent and scoped, interpreted evaluation
The point of evaluating the use cases inside a program is to handle exceptions and rollbacks within a single ACID transaction. So we ask the LLM to write a program that answers the user's prompt, then we evaluate that program. The dynamic language chosen here is Rhai (rhai.rs).
Take the following prompt: "Transfer 10 euros from Bob to Carla, then from Carla to Bob."
The Rhai program written by the model:
transfer(#{ from: "Bob", to: "Carla", amount: 10 });
transfer(#{ from: "Carla", to: "Bob", amount: 10 });
Each annotated use case becomes a Rhai function with the same name. When the program calls transfer(#{ ... }), its parameters fill in a Transfer, then execute is called: it is exactly the same use case as at the start of the article, the one used in the direct call and in the test.
The program is evaluated in one go, on a copy of the accounts. If any call fails, the whole program is cancelled: transfers already made are undone, and the accounts stay exactly as they were before.
For example, this program sends €20 to Bob, to Carla, then to Dan, who has no account:
for friend in ["Bob", "Carla", "Dan"] {
transfer(#{ from: "Alice", to: friend, amount: 20 });
}
The third transfer fails, and the first two are cancelled along with it:
{
"changed": "nothing",
"error": "Runtime error: transfer: no account Dan (line 3, position 13)"
}
To receive the result, the agent does what the test does: it provides its own implementation of the output port. The use case calls it through success or failure, without knowing it is talking to an AI, and the result goes back to the model as JSON. This is the dependency inversion from the introduction again: the AI is just one more caller.
Two safeguards complete the picture. The program is only evaluated after the user confirms it. And Rhai can only call the functions registered with it: the use cases, and nothing else (no files, no network). A cap on the number of calls and operations also keeps a program from running forever.
Conclusion
In the end, a request to the agent always follows the same path:
- The user writes their request.
- The model reads the directory and asks for the sheets of the use cases it needs.
- It writes a Rhai program that calls them.
- The user confirms.
- The program is evaluated, all or nothing.
- The result goes back to the model, which answers the user.
That is the whole point of the approach: in Rust, the Clean Architecture use case and the AI agent's tool are one and the same. There is no business code on one side and a hand-written tool definition for the model on the other: it is the same Transfer struct. Its fields are both the use case's parameters and the tool's, and the #[agent] annotation only adds what the model needs to read.
The compiler keeps them in sync. A parameter added to the use case shows up in the AI's sheet on its own. A field without a description, or of a type the macro does not know, does not compile. The result type is checked between the use case and what goes back to the model. The tool definition can therefore never drift from the code it calls.
The business rules are written once, in the use cases. The UI, the tests and the agent all go through them, and the agent cannot do anything the use cases do not allow.
I have the full demo code available: write to me if you would like a copy.