DEV Community

LLMGraph
LLMGraph

Posted on

Hexagonal architecture in Rust, and why it makes your codebase legible to coding agents

Coding agents are good at local edits and bad at holding a whole system in their head. Ask one to fix a function and it does fine. Ask it to "add Postgres support" to a codebase where the database calls are smeared across HTTP handlers, and it starts editing in six places, guessing at each, and quietly breaking the seventh. The problem is not the model. The problem is that the codebase never told anyone, human or machine, where things are allowed to live.

Hexagonal architecture is a way of telling them. It is an old idea (Alistair Cockburn named it "ports and adapters" in 2005) that turns out to be exactly the structure an agent needs: narrow contracts, bounded blast radius, and a spec it can read before it writes. This post walks through what it is, how it looks in Rust, and why the same boundaries that keep code readable for people make it tractable for agents.

There is a companion repo that compiles, tests, and runs: llmgraph-ai/hexagonal-rust-template. Everything below is in there.

The one rule

Hexagonal architecture has a lot of vocabulary (ports, adapters, driving side, driven side) but only one rule that matters:

Dependencies point inward. The core depends on nothing.

Your business logic sits in the middle. It defines the interfaces it needs from the outside world (a place to store data, a way to send email) as traits. It does not know or care what implements them. Everything technological (the web framework, the database driver, the JSON) lives at the edge and depends on the core, never the other way around.

Draw it as a picture and it is a set of rings with the arrows all pointing to the center:

server  ->  adapters  ->  application  ->  domain
                \______________________________^
Enter fullscreen mode Exit fullscreen mode
  • domain is the core: business types and the ports (traits).
  • application is the use cases, written against those ports.
  • adapters are the edge: an HTTP handler, a Postgres client, an in-memory fake.
  • server is the composition root: the one place that wires a specific adapter to the core.

In Rust, the compiler enforces it

Most languages document the dependency rule and hope. Rust lets you make it a compile error. Put each ring in its own crate, and give each crate a dependencies list that only points inward. Now the core cannot import an adapter, because the adapter's crate is not in its dependency graph. The rule stops being a convention that erodes over time and becomes a fact the compiler checks on every build.

Here is the core. Note what is not imported: no axum, no sqlx, no serde. Just the standard library and two small utilities.

// crates/domain/src/lib.rs
pub struct ShortCode(String);

impl ShortCode {
    // The inner String is private, so the only way to get a ShortCode is to
    // parse one. An invalid code cannot exist anywhere in the system.
    pub fn parse(raw: &str) -> Result<Self, DomainError> {
        let ok = (3..=32).contains(&raw.len())
            && raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
        if ok { Ok(Self(raw.to_string())) } else { Err(DomainError::InvalidCode) }
    }
}
Enter fullscreen mode Exit fullscreen mode

The core also defines the ports: the interfaces it needs from the world, as traits.

// crates/domain/src/ports.rs

// A driven (outbound) port: somewhere to store links. The core does not know
// if this is Postgres, Redis, or a HashMap.
#[async_trait]
pub trait LinkRepository: Send + Sync {
    async fn save(&self, link: &ShortLink) -> Result<(), DomainError>;
    async fn find(&self, code: &ShortCode) -> Result<Option<ShortLink>, DomainError>;
}

// A driving (inbound) port: the use cases the application offers to the world.
#[async_trait]
pub trait Shortener: Send + Sync {
    async fn shorten(&self, target: &str, code: Option<&str>) -> Result<ShortLink, DomainError>;
    async fn resolve(&self, code: &str) -> Result<ShortLink, DomainError>;
}
Enter fullscreen mode Exit fullscreen mode

The application layer implements the driving port by orchestrating the driven one. This file is the behavior of the system with none of the plumbing:

// crates/application/src/lib.rs
pub struct ShortenerService {
    repo: Arc<dyn LinkRepository>, // depends on the port, not a concrete DB
}

#[async_trait]
impl Shortener for ShortenerService {
    async fn shorten(&self, target: &str, code: Option<&str>) -> Result<ShortLink, DomainError> {
        Self::validate_target(target)?;
        let code = match code {
            Some(raw) => ShortCode::parse(raw)?,
            None => ShortCode::parse(&generate_code())?,
        };
        if self.repo.find(&code).await?.is_some() {
            return Err(DomainError::CodeTaken);
        }
        let link = ShortLink { code, target: target.to_string() };
        self.repo.save(&link).await?;
        Ok(link)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The HTTP adapter depends on the Shortener port, not the concrete service. Its only job is to translate HTTP into a call on the port and translate the result back. There is no business logic here, which is exactly why you can throw it away and replace it with a CLI without touching the core:

// crates/adapters/src/http.rs
async fn create_link(
    State(state): State<AppState>,
    Json(req): Json<CreateLinkRequest>,
) -> Result<(StatusCode, Json<LinkResponse>), ApiError> {
    let link = state.shortener.shorten(&req.target, req.code.as_deref()).await?;
    Ok((StatusCode::CREATED, Json(link.into())))
}
Enter fullscreen mode Exit fullscreen mode

And the composition root, the single file that knows every concrete type:

// crates/server/src/main.rs
let repo: Arc<dyn LinkRepository> = Arc::new(InMemoryLinkRepository::default());
let shortener: Arc<dyn Shortener> = Arc::new(ShortenerService::new(repo));
let app = http::router(shortener);
axum::serve(listener, app).await.unwrap();
Enter fullscreen mode Exit fullscreen mode

Want Postgres instead of memory? Write a PostgresLinkRepository that implements LinkRepository, and change line one. Want a CLI instead of HTTP? Change line three. Nothing upstream notices, because nothing upstream ever knew.

Why agents thrive on this

Now the payoff, and the reason this old pattern is worth revisiting in 2026. Every property that makes hexagonal architecture pleasant for a human maps directly onto something an agent needs.

Tasks map to a single file with a precise contract. "Add a Postgres adapter" is not a vague request that could touch anything. It is: implement this one trait in this one new file. The agent has the exact signature to satisfy and physically cannot reach into business logic while doing it, because the adapter crate does not depend on private application internals. The task is scoped by the architecture, not by your prompt.

The ports are the spec. An agent can write a correct use-case test from the trait definition alone, before a single adapter exists. In the template, the use-case tests run against a five-line fake repository:

#[tokio::test]
async fn shorten_then_resolve_roundtrips() {
    let service = ShortenerService::new(Arc::new(FakeRepo::default()));
    let created = service.shorten("https://example.com", Some("my-code")).await.unwrap();
    let resolved = service.resolve("my-code").await.unwrap();
    assert_eq!(created, resolved);
}
Enter fullscreen mode Exit fullscreen mode

No database, no HTTP server, no fixtures. An agent asked to raise test coverage on the core has everything it needs from the port and nothing it needs to mock away.

Blast radius is bounded by construction. This is the big one. When an agent makes a wrong turn in a well-layered codebase, the mistake stays inside the layer it was working in. A bad decision in the HTTP adapter cannot corrupt the domain, because the compiler will not let the domain see the adapter. You get to be wrong locally, which is the only kind of wrong that is cheap to fix. In a codebase where a database call can appear inside a request handler, there is no such floor. A confused edit propagates.

The structure survives context limits. An agent does not need to load the whole repository to work on one adapter. The relevant surface is the port it implements plus the crate it lives in. The architecture pre-chunks the codebase into units small enough to reason about in isolation, which is the same reason it was pleasant for humans in the first place. Good structure is good structure; agents just raise the stakes on having it.

Where to start

Do not start by rewriting everything into four crates. Start with the one rule and one seam. Pick the dependency that hurts most (usually the database) and define it as a trait in your core. Move the concrete client behind an adapter that implements the trait. Wire it up in one place. You will feel the core get quieter almost immediately, because it stops importing things that have nothing to do with your business.

Then, if it earns its keep, split the rings into crates so the compiler enforces the rule you have been maintaining by hand. Rust makes that enforcement free, and free enforcement is the difference between an architecture you have and an architecture you had six months ago.

The full template compiles, tests, and runs: github.com/llmgraph-ai/hexagonal-rust-template. Clone it, run cargo test, delete the URL shortener, and keep the shape.


This is the kind of structured backend we care about at LLMGraph, where we build LLM and AI-agent workflows that people deploy as APIs. Legible structure is what lets both our team and our tools move fast without breaking the parts that matter.

Top comments (0)