·VegaLoop Team

Hexagonal Architecture in Practice

How ports and adapters keep a growing codebase honest.

architecturerustci-cd

Every codebase starts clean. You have a handful of files, clear responsibilities, and the confidence that this time you’ll keep things organized. Then features pile up. The database query that was in one place creeps into three. Your HTTP handler starts making decisions about business logic. Six months later, changing how you store data means rewriting half your tests.

Hexagonal architecture is one way to prevent that drift. The core idea is simple: your business logic should never know how it talks to the outside world.

The shape of the problem

Most applications start with a layered approach. You have a web layer, a service layer, and a data layer. Requests flow top to bottom. It works until it doesn’t.

The trouble starts when your “service” layer accumulates knowledge it shouldn’t have. It knows you’re using a specific database. It knows the shape of your HTTP responses. It knows how your message queue serializes events. Every integration point becomes a dependency that radiates inward.

When you want to swap your email provider, you’re editing business logic files. When you want to test a calculation, you need a running database. The boundaries eroded so gradually that nobody noticed.

Layered architecture doesn’t prevent this. It just organizes the erosion into neat folders. The problem isn’t structure. It’s dependency direction.

Ports and adapters, plainly

Hexagonal architecture (sometimes called ports and adapters) draws a hard line. On one side: your domain logic. On the other: everything else.

Ports are interfaces. They describe what the domain needs without specifying how it gets fulfilled. “I need to save a user record” is a port. “I need to query PostgreSQL on port 5432” is not.

Adapters are implementations that satisfy those ports. A PostgreSQL adapter. An in-memory adapter for tests. An S3 adapter for file storage. The domain doesn’t care which one it’s talking to.

This isn’t theoretical. It changes how you write code daily. Your domain module compiles and runs its tests without any infrastructure. No test database. No Docker containers spinning up. No network calls. The feedback loop shrinks from minutes to seconds.

The “hexagonal” name comes from Alistair Cockburn’s original 2005 paper. The hexagon shape is arbitrary. It just signals that there are many possible sides, many possible integration points, and none of them are more important than any other. Your HTTP API is no more privileged than your database connection or your event bus. They’re all adapters.

Why this matters for a wellness platform

A platform that tracks nutrition, training, and goals touches a lot of external systems. Device syncs. Calculation engines. Notification services. Data stores. Each of those is an integration point that could entangle itself with core logic.

Consider something like training load calculation. The math itself is pure. Inputs go in, numbers come out. That logic has no reason to know whether the activity data came from a watch, a manual entry, or a CSV import. Hexagonal architecture makes that separation explicit and enforceable, not just a convention someone wrote in a README.

The same principle applies to nutrition tracking. Calculating whether someone hit their protein target for the day is domain logic. Fetching food data from an external source is an adapter. Persisting the result is another adapter. The calculation never changes regardless of where the data lives.

When we built cross-domain intelligence to connect nutrition patterns with training performance, hexagonal architecture made the feature possible without a rewrite. The domain logic that correlates recovery quality with macro intake doesn’t know whether the nutrition data came from manual logging or a barcode scan. It receives data through a port and produces insights. The adapter layer handles the messy reality of multiple input sources.

What it looks like in Rust

Rust’s trait system maps naturally to hexagonal architecture. A port is a trait. An adapter is a struct that implements that trait. The compiler enforces the boundary.

// Port: what the domain needs
trait ActivityRepository {
    fn save(&self, activity: &Activity) -> Result<(), DomainError>;
    fn find_by_date_range(&self, user: UserId, from: Date, to: Date) -> Result<Vec<Activity>, DomainError>;
}

// Domain logic: no knowledge of storage
fn weekly_training_load(
    repo: &impl ActivityRepository,
    user: UserId,
    week_start: Date,
) -> Result<LoadSummary, DomainError> {
    let activities = repo.find_by_date_range(user, week_start, week_start + Days(7))?;
    Ok(calculate_load(&activities))
}

The function weekly_training_load doesn’t know if it’s talking to PostgreSQL, SQLite, or a Vec in memory. During tests, you hand it a mock. In production, you hand it the real adapter. The logic is identical either way.

We wrote about why Rust’s type system appealed to us early on. Hexagonal architecture is one of the patterns where that choice compounds. The compiler won’t let you accidentally bypass a port. If your domain function asks for a trait, you cannot hand it a raw database connection without implementing that trait first.

Driving vs. driven ports

One distinction worth making explicit: not all ports face the same direction.

Driving ports are how the outside world calls into your domain. An HTTP handler receives a request and invokes a domain use case. A CLI command parses arguments and calls the same use case. The domain exposes an interface that external callers use to trigger actions.

Driven ports are how your domain reaches outward. It needs to persist data, send a notification, or fetch an external resource. The domain defines what it needs, and the adapter fulfills it.

// Driving port: the outside world calls in
trait CreateGoalUseCase {
    fn execute(&self, cmd: CreateGoalCommand) -> Result<Goal, DomainError>;
}

// Driven port: the domain reaches out
trait GoalRepository {
    fn save(&self, goal: &Goal) -> Result<(), DomainError>;
    fn find_active(&self, user: UserId) -> Result<Vec<Goal>, DomainError>;
}

This distinction matters because it clarifies who owns the interface definition. Driving ports belong to the domain. They define what operations exist. Driven ports also belong to the domain. They define what the domain needs from the outside. In both cases, the domain is the center, and it never bends to accommodate external concerns.

A practical example: when a user logs a meal, the HTTP adapter translates the request into a domain command and calls the driving port. The domain validates the input, calculates macros, checks against daily targets, and then calls a driven port to persist the result. The domain never imported anything from the HTTP or database crates.

The testing payoff

This is where hexagonal architecture earns its keep. Your domain tests become fast, deterministic, and focused.

No test database to seed. No cleanup scripts. No flaky network calls. You write an in-memory adapter that stores data in a HashMap, inject it, and test pure behavior. A full domain test suite can run in milliseconds.

struct InMemoryActivityRepo {
    records: RefCell<Vec<Activity>>,
}

impl ActivityRepository for InMemoryActivityRepo {
    fn save(&self, activity: &Activity) -> Result<(), DomainError> {
        self.records.borrow_mut().push(activity.clone());
        Ok(())
    }

    fn find_by_date_range(&self, user: UserId, from: Date, to: Date) -> Result<Vec<Activity>, DomainError> {
        Ok(self.records.borrow()
            .iter()
            .filter(|a| a.user == user && a.date >= from && a.date <= to)
            .cloned()
            .collect())
    }
}

That in-memory adapter is maybe fifteen lines of code. It fulfills the exact same contract as the production DynamoDB adapter. Your domain tests run against it at in-process speed. There’s no network latency, no cold starts, no flakiness from shared test environments.

Integration tests still exist. You still verify that your PostgreSQL adapter actually talks to PostgreSQL correctly. But those tests are isolated to the adapter layer. They test one thing: does this adapter correctly fulfill the port contract? They don’t re-test your business logic.

The result is a test suite that runs fast enough to include in every CI check without slowing anyone down. We’ve talked about taming Rust compile times in our CI pipeline. Fast domain tests are part of that story. When your domain crate has no database dependencies, it compiles and tests independently without waiting for heavier crates to build.

Error boundaries and domain purity

One subtle benefit of hexagonal architecture: your error types stay clean.

Without it, domain functions accumulate error variants from every integration. sqlx::Error, reqwest::Error, serde_json::Error. Your domain’s error enum becomes a grab-bag of infrastructure concerns. Matching on errors becomes painful. Callers need to understand database internals to handle failures gracefully.

With ports and adapters, the adapter layer translates infrastructure errors into domain errors before they cross the boundary.

// Domain error: speaks the language of the business
enum DomainError {
    NotFound,
    ValidationFailed(String),
    Conflict,
    Unavailable,
}

// Adapter translates infrastructure errors
impl ActivityRepository for DynamoActivityRepo {
    fn save(&self, activity: &Activity) -> Result<(), DomainError> {
        self.client.put_item(/* ... */)
            .map_err(|e| match e {
                SdkError::ServiceError(ref se) if se.err().is_conditional_check_failed() => DomainError::Conflict,
                _ => DomainError::Unavailable,
            })
    }
}

The domain never sees AWS SDK types. It works with its own vocabulary. This keeps the domain portable and makes error handling predictable for anyone reading the code.

Where it gets uncomfortable

Hexagonal architecture adds indirection. You have more files, more traits, more wiring. For a small project or a prototype, that overhead isn’t worth it. You’re paying upfront cost for long-term flexibility you might not need yet.

The discipline also requires vigilance. It’s tempting to “just this once” let a handler reach into the domain without going through a port. Every shortcut weakens the boundary. Six months of shortcuts and you’re back where you started.

There’s also a learning curve for teams. The pattern is conceptually simple but requires everyone to agree on where boundaries live. Domain logic that accidentally depends on an adapter-layer type is a boundary violation, even if it compiles.

Another real cost: you end up writing more trait definitions than you initially expect. Every new external dependency your domain needs means defining a new port, implementing it for production, implementing it for tests, and wiring everything together. For features that touch three or four external systems, the boilerplate adds up. You pay less of this cost in languages with dynamic dispatch or duck typing, but in Rust, it’s explicit.

Enforcing boundaries in CI

Discipline erodes without enforcement. People get busy, deadlines loom, and someone adds a use aws_sdk_dynamodb inside the domain crate. It compiles. Tests pass. The violation goes unnoticed until it causes pain later.

You can catch this mechanically. In a Rust workspace, your domain crate’s Cargo.toml should have zero infrastructure dependencies. No database drivers, no HTTP clients, no SDK crates. If someone adds one, CI catches it before merge.

A simple check: parse the domain crate’s dependency list and fail the build if anything unexpected appears. This is cheaper than code review for catching boundary violations because it never gets tired, never misses a line, and never thinks “it’s probably fine.”

Beyond dependency lists, you can enforce boundaries with module visibility. Rust’s pub(crate) lets you expose types within a crate while hiding them from the outside. Domain types stay inside the domain crate. Adapter crates import domain types, never the reverse.

When to reach for it

Small scripts, weekend prototypes, and throwaway tools don’t need this. The overhead isn’t justified when you’re exploring.

It starts paying off when your codebase will live for years, when multiple people contribute, and when you need confidence that changes in one area won’t ripple unpredictably. A platform that handles real user data across nutrition, activity, and goal tracking qualifies.

The pattern also shines when you expect your infrastructure to change. Early on, you might store data one way. A year later, you might split into separate services or swap a vendor. Adapters make those changes surgical rather than systemic.

There’s a middle ground that works well in practice: start with a simple module structure, but keep your domain functions free of infrastructure imports from day one. You don’t need the full hexagonal ceremony upfront. But if your domain function already takes a trait rather than a concrete database type, you’ve preserved the option to formalize the architecture later without a rewrite.

The honest version

Hexagonal architecture isn’t a silver bullet. It’s a trade-off. You get testability, clear boundaries, and the ability to swap infrastructure without rewriting logic. You pay with indirection, more files, and the ongoing discipline to maintain the boundary.

For VegaLoop, that trade-off has been worth it. The domain logic that calculates recovery windows or progression targets doesn’t care how data arrives or where it goes. It focuses on one job: being correct. Everything else is just plumbing.

The codebase is larger than it would be without this pattern. There are more files, more traits, more adapter implementations sitting next to each other. But when something breaks, you know where to look. When a requirement changes, you know what to touch and, more importantly, what you won’t need to touch. That predictability compounds over months and years in ways that are hard to appreciate until you’ve lived without it.

Note: This article is for general information only and isn't medical advice. Everyone responds to training and nutrition differently. Talk to a doctor or qualified professional before making changes to your training or nutrition.