Skip to content

← all backend comparisons

tradeoffs

Interfaces, traits, and the cost of mocking

Go developers reach for an interface the moment they want to test something. Rust has three takes on the same shape — concrete, dyn via async_trait, native generics — and none of them is as ergonomic as Go's implicit interfaces. This page makes that comparison directly.

Go (chi · sqlc · pgx)
Go: interface + implicit satisfaction (real and fake share no code)
docs/abstractions/go-interface.go
// shop-two-backends not found at build time
Rust (axum · sqlx · tokio)
Rust option A — concrete (what this codebase actually does)
docs/abstractions/rust-concrete.rs
// shop-two-backends not found at build time
Rust option B — async_trait macro (boxed futures, dyn)
docs/abstractions/rust-async-trait.rs
// shop-two-backends not found at build time
Rust option C — native async-trait via generics (viral type param)
docs/abstractions/rust-generics.rs
// shop-two-backends not found at build time

What to take away

The Go side is the easy half: declare an interface where you use it, real type satisfies it implicitly, fake type satisfies it implicitly, swap them at the constructor. No annotations, no heap, no generics. Just three keywords and a method.

Rust forces a choice that Go doesn't. Each option costs something different.

A. Concrete (what this codebase ships)

Hold the real PgPool directly. No trait, no mock. Tests use testcontainers or transactional rollback against a real Postgres. Cost: tests need Docker (or a live DB) and run slower than in-memory mocks.

Pick when the data layer is the thing you most want to test — the queries themselves are where bugs live.

B. async_trait (Go-mimicry)

Re-introduce the interface pattern via the async_trait crate, which boxes the returned future so the trait is dyn-dispatchable. Now you can hold Arc<dyn ProductStore> and swap a mock just like Go. Cost: one heap allocation per call, plus the macro noise on every impl.

Pick when you have a Go team that's just adopted Rust and the unfamiliarity of "real DB in tests" is slowing them down. The allocation is irrelevant for I/O-bound code.

C. Native async-trait via generics

Stable since Rust 1.75. Define the trait with native async fn and parameterize the handler over S: ProductStore. No Box, no dyn, no macro. Cost: the type parameter is viral — it propagates into every router, every helper, every test harness that touches state.

Pick when you have one or two stores, performance matters at the call site, and the codebase is small enough that the type-parameter ergonomics don't bite.

The honest comparison

Go's interface is small, ergonomic, and free. Rust's three options each trade something — Docker dependency, allocation, or generic propagation — for the same testability you got for nothing in Go. That's not a flaw in Rust; it's the price of stronger static guarantees about who-owns-what and where futures live.

This codebase's choice (option A — concrete) is the smallest one. The other two are fine; they're just unnecessary at this size, and "I'd reach for an abstraction here" is itself a Go reflex worth examining when you cross the language boundary.