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.
// shop-two-backends not found at build time // shop-two-backends not found at build time // shop-two-backends not found at build time // 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.