Lesson 94 of 94
Architecting Rust Services
The previous lessons covered how individual pieces of Rust work. A lead's job is also to decide how the pieces fit together in a codebase that twenty people change every day, and how that team works. Rust has no dominant framework that makes these decisions for you. That's freedom, and it's also a common source of inconsistent codebases.
This lesson describes the structure most production Rust services converge on, then the team practices that keep them healthy. None of it is mandatory; all of it has been tested by teams running Rust in production.
| Concern | Rust practice |
|---|---|
| Wiring dependencies | explicit wiring in main (the composition root) |
| Abstractions | traits at real boundaries (storage, external services, clocks) |
| Transactions and cross-cutting concerns | explicit transaction objects, middleware (tower layers) |
| Error handling at the HTTP edge | error enums mapped to responses in one place (IntoResponse) |
| Configuration | a typed config struct, loaded and validated once at startup |
| Shared application state | an AppState struct cloned into handlers (Arc inside) |
| Domain objects | domain types whose constructors enforce invariants |
Part 1: Structure
1. Layers and the direction of dependencies
Most services separate three kinds of code, whatever the layers are called:
graph TD
B[bin: main.rs<br/>config, wiring, startup] --> H[http adapter<br/>axum routes, DTOs, error mapping]
B --> P[postgres adapter<br/>SQL, row mapping]
B --> A
H --> A[application<br/>use cases, ports as traits]
P --> A
A --> D[domain<br/>types, rules, no I/O]- The domain has the business types and rules. It does no I/O and depends on nothing but the standard library and a few small crates (
thiserror,serdeif you must). - The application layer implements use cases ("place an order") in terms of the domain and of ports: traits describing what it needs from the outside world (store orders, charge a card, read the time).
- Adapters implement ports (a Postgres repository, an HTTP payment client) and expose use cases (HTTP routes, queue consumers).
mainreads configuration, builds the adapters, and wires everything together.
Arrows point inward: the domain doesn't know about HTTP or SQL. That's what lets you test business rules in microseconds and swap Postgres for an in-memory store in tests. In a small service these are modules; in a large one they become crates in a workspace (example 10), and the compiler then enforces the direction.
2. The domain: types that can't be invalid
The domain layer is where Rust's type system pays off most. Validate once, at construction, and represent the result in a type that can't hold an invalid value (API Design Idioms covers the techniques). Everything downstream can then trust it.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money {
cents: u64,
}
impl Money {
pub fn from_cents(cents: u64) -> Self {
Money { cents }
}
pub fn cents(self) -> u64 {
self.cents
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}.{:02}", self.cents / 100, self.cents % 100)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sku(String);
impl Sku {
pub fn parse(s: &str) -> Result<Self, DomainError> {
let valid = s.len() == 8 && s.as_bytes()[3] == b'-' && s[..3].chars().all(|c| c.is_ascii_uppercase()) && s[4..].chars().all(|c| c.is_ascii_digit());
if valid { Ok(Sku(s.to_string())) } else { Err(DomainError::InvalidSku(s.to_string())) }
}
}
#[derive(Debug, PartialEq)]
pub enum DomainError {
InvalidSku(String),
EmptyOrder,
TooManyItems { max: usize },
}
#[derive(Debug)]
pub struct OrderLine {
pub sku: Sku,
pub quantity: u32,
pub unit_price: Money,
}
#[derive(Debug)]
pub struct Order {
lines: Vec<OrderLine>, // private: only valid orders can be built
}
impl Order {
pub const MAX_LINES: usize = 50;
pub fn new(lines: Vec<OrderLine>) -> Result<Self, DomainError> {
if lines.is_empty() {
return Err(DomainError::EmptyOrder);
}
if lines.len() > Self::MAX_LINES {
return Err(DomainError::TooManyItems { max: Self::MAX_LINES });
}
Ok(Order { lines })
}
pub fn total(&self) -> Money {
Money::from_cents(self.lines.iter().map(|l| l.unit_price.cents() * l.quantity as u64).sum())
}
}
fn main() {
let line = |sku: &str, q, c| Ok::<_, DomainError>(OrderLine { sku: Sku::parse(sku)?, quantity: q, unit_price: Money::from_cents(c) });
let order = Order::new(vec![line("ABC-1234", 2, 1999).unwrap(), line("XYZ-0001", 1, 500).unwrap()]).unwrap();
println!("total: {}", order.total());
println!("{:?}", Sku::parse("abc-12"));
println!("{:?}", Order::new(vec![]).map(|o| o.total()));
}total: 44.98
Err(InvalidSku("abc-12"))
Err(EmptyOrder)Note what's absent: no database ids, no serde attributes for the HTTP format, no Option fields that are "always set after save". Those belong to the adapters, which convert to and from domain types at the boundary.
3. Ports: traits for what the application needs
A port is a trait the application layer defines for something outside it. Keep ports narrow and named after what the use case needs, not after a technology. Here a use case needs to store orders and read the current time:
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq)]
pub struct OrderId(pub u64);
#[derive(Debug, Clone)]
pub struct StoredOrder {
pub id: OrderId,
pub customer: String,
pub total_cents: u64,
pub placed_at: u64,
}
#[derive(Debug)]
pub enum RepoError {
Unavailable(String),
}
// Ports, defined by the application layer.
pub trait OrderRepository {
async fn save(&self, customer: &str, total_cents: u64, placed_at: u64) -> Result<OrderId, RepoError>;
async fn by_customer(&self, customer: &str) -> Result<Vec<StoredOrder>, RepoError>;
}
pub trait Clock {
fn now_unix(&self) -> u64;
}
// The use case depends on the ports, not on Postgres or the system clock.
pub struct PlaceOrder<R, C> {
pub repo: R,
pub clock: C,
pub max_orders_per_customer: usize,
}
#[derive(Debug)]
pub enum PlaceOrderError {
LimitReached,
Storage(RepoError),
}
impl<R: OrderRepository, C: Clock> PlaceOrder<R, C> {
pub async fn execute(&self, customer: &str, total_cents: u64) -> Result<OrderId, PlaceOrderError> {
let existing = self.repo.by_customer(customer).await.map_err(PlaceOrderError::Storage)?;
if existing.len() >= self.max_orders_per_customer {
return Err(PlaceOrderError::LimitReached);
}
self.repo.save(customer, total_cents, self.clock.now_unix()).await.map_err(PlaceOrderError::Storage)
}
}
// Adapters for tests: an in-memory repository and a fixed clock.
#[derive(Default)]
pub struct InMemoryOrders {
orders: Mutex<HashMap<u64, StoredOrder>>,
}
impl OrderRepository for InMemoryOrders {
async fn save(&self, customer: &str, total_cents: u64, placed_at: u64) -> Result<OrderId, RepoError> {
let mut orders = self.orders.lock().unwrap();
let id = orders.len() as u64 + 1;
orders.insert(id, StoredOrder { id: OrderId(id), customer: customer.to_string(), total_cents, placed_at });
Ok(OrderId(id))
}
async fn by_customer(&self, customer: &str) -> Result<Vec<StoredOrder>, RepoError> {
Ok(self.orders.lock().unwrap().values().filter(|o| o.customer == customer).cloned().collect())
}
}
pub struct FixedClock(pub u64);
impl Clock for FixedClock {
fn now_unix(&self) -> u64 {
self.0
}
}
#[tokio::main]
async fn main() {
let use_case = PlaceOrder { repo: InMemoryOrders::default(), clock: FixedClock(1_790_000_000), max_orders_per_customer: 2 };
for _ in 0..3 {
println!("{:?}", use_case.execute("ana", 4_498).await);
}
let stored = use_case.repo.by_customer("ana").await.unwrap();
println!("stored {} orders, placed at {}", stored.len(), stored[0].placed_at);
}Ok(OrderId(1))
Ok(OrderId(2))
Err(LimitReached)
stored 2 orders, placed at 1790000000The use case is tested with no database, no network, and no real clock, and the test is deterministic. Only put a trait where there are genuinely two implementations (production and test, or two providers). A trait with one implementation "for flexibility" is usually needless indirection.
4. Generics or dyn for ports?
The previous example makes the use case generic over its ports (PlaceOrder<R, C>). The alternative is to store Arc<dyn OrderRepository>. Both are common; they trade differently:
Generic (PlaceOrder<R, C>) |
Trait object (Arc<dyn OrderRepository>) |
|
|---|---|---|
| Call cost | static, inlinable | one indirect call (negligible next to I/O) |
| Type signatures | generic parameters spread to everything that holds the use case | simple, concrete types |
| Compile time | one copy per combination of adapters | one copy |
async fn in the trait |
works natively | not directly: needs boxed futures (async-trait or a manual Pin<Box<dyn Future>>) |
| Choosing the implementation at runtime (config flag) | awkward | natural |
A native async fn in a trait isn't dyn-compatible: the compiler can't make a vtable for a method whose future type differs per implementation. #[async_trait] rewrites the methods to return Pin<Box<dyn Future + Send>>, which makes the trait usable as dyn, at the cost of one allocation per call:
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait PaymentGateway: Send + Sync {
async fn charge(&self, customer: &str, cents: u64) -> Result<String, String>;
}
pub struct StripeGateway;
pub struct FakeGateway;
#[async_trait]
impl PaymentGateway for StripeGateway {
async fn charge(&self, customer: &str, cents: u64) -> Result<String, String> {
Ok(format!("stripe charge for {customer}: {cents}"))
}
}
#[async_trait]
impl PaymentGateway for FakeGateway {
async fn charge(&self, _customer: &str, cents: u64) -> Result<String, String> {
if cents > 100_000 { Err("declined".into()) } else { Ok("fake-charge-1".into()) }
}
}
fn gateway_from_config(name: &str) -> Arc<dyn PaymentGateway> {
match name {
"stripe" => Arc::new(StripeGateway),
_ => Arc::new(FakeGateway), // chosen at runtime from configuration
}
}
#[tokio::main]
async fn main() {
for name in ["stripe", "fake"] {
let gateway = gateway_from_config(name);
println!("{name}: {:?}", gateway.charge("ana", 4_498).await);
}
println!("fake, big amount: {:?}", gateway_from_config("fake").charge("bo", 500_000).await);
}stripe: Ok("stripe charge for ana: 4498")
fake: Ok("fake-charge-1")
fake, big amount: Err("declined")A practical default: generics inside a crate for small, hot abstractions; Arc<dyn Trait> for adapters chosen at startup (repositories, gateways, clocks) held in application state, where a boxed future per call is irrelevant next to a network round trip.
5. Wiring in main: the composition root
Without a DI container, main builds the object graph by hand: read config, create adapters, pass them to use cases, hand those to the HTTP layer. It's a few dozen lines, it's explicit, and every dependency is visible in one place. When it gets long, split it into a build_app(config) -> App function that tests can call too.
use std::sync::Arc;
// Ports (simplified to synchronous for brevity).
trait Clock: Send + Sync {
fn now(&self) -> u64;
}
trait Notifier: Send + Sync {
fn notify(&self, msg: &str) -> String;
}
// Adapters.
struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> u64 {
1_790_000_000 // stands in for SystemTime::now()
}
}
struct EmailNotifier {
smtp_host: String,
}
impl Notifier for EmailNotifier {
fn notify(&self, msg: &str) -> String {
format!("email via {}: {msg}", self.smtp_host)
}
}
// Use cases hold what they need.
struct ShipOrder {
clock: Arc<dyn Clock>,
notifier: Arc<dyn Notifier>,
}
impl ShipOrder {
fn execute(&self, order_id: u64) -> String {
self.notifier.notify(&format!("order {order_id} shipped at {}", self.clock.now()))
}
}
// Application state handed to HTTP handlers: cheap to clone.
#[derive(Clone)]
struct AppState {
ship_order: Arc<ShipOrder>,
}
struct Config {
smtp_host: String,
}
// The composition root.
fn build_app(config: &Config) -> AppState {
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
let notifier: Arc<dyn Notifier> = Arc::new(EmailNotifier { smtp_host: config.smtp_host.clone() });
AppState { ship_order: Arc::new(ShipOrder { clock, notifier }) }
}
fn main() {
let config = Config { smtp_host: "smtp.internal:587".into() };
let state = build_app(&config);
println!("{}", state.ship_order.execute(1001));
}email via smtp.internal:587: order 1001 shipped at 1790000000Wiring by hand gives up automatic wiring and gains two things: a missing dependency is a compile error rather than a startup failure, and there's no hidden magic to debug. Crates for DI containers exist, but few production Rust teams use them.
6. Error boundaries: one enum per layer, mapped once
Each layer has its own error type, describing failures in its own terms. Adapters translate errors from the layer below, and the HTTP layer maps application errors to status codes in one place, the equivalent of @ControllerAdvice. With axum, that's an IntoResponse implementation:
use axum::{Json, Router, extract::Path, http::StatusCode, response::{IntoResponse, Response}, routing::get};
use serde_json::json;
use tower::ServiceExt;
// Application-layer error: what can go wrong, in business terms.
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("order {0} not found")]
NotFound(u64),
#[error("order {0} was already shipped")]
AlreadyShipped(u64),
#[error("storage unavailable")]
Storage(#[source] std::io::Error),
}
// The single place where errors become HTTP responses.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "order_not_found"),
AppError::AlreadyShipped(_) => (StatusCode::CONFLICT, "order_already_shipped"),
AppError::Storage(e) => {
tracing::error!(error = %e, "storage failure"); // log details, don't leak them
(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable")
}
};
let message = if status.is_server_error() { "please retry later".to_string() } else { self.to_string() };
(status, Json(json!({ "error": code, "message": message }))).into_response()
}
}
async fn ship(Path(id): Path<u64>) -> Result<Json<serde_json::Value>, AppError> {
match id {
1 => Ok(Json(json!({ "order": 1, "status": "shipped" }))),
2 => Err(AppError::AlreadyShipped(2)),
3 => Err(AppError::Storage(std::io::Error::other("connection refused"))),
other => Err(AppError::NotFound(other)),
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/orders/{id}/ship", get(ship));
for id in [1, 2, 3, 9] {
let req = axum::http::Request::get(format!("/orders/{id}/ship")).body(axum::body::Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status().as_u16();
let body = axum::body::to_bytes(res.into_body(), 1024).await.unwrap();
println!("{id}: {status} {}", String::from_utf8_lossy(&body));
}
}1: 200 {"order":1,"status":"shipped"}
2: 409 {"error":"order_already_shipped","message":"order 2 was already shipped"}
3: 503 {"error":"temporarily_unavailable","message":"please retry later"}
9: 404 {"error":"order_not_found","message":"order 9 not found"}Guidelines: thiserror enums for the domain and application layers (callers match on them), anyhow only at the very top (main, CLI tools, scripts), internal details logged but never returned to clients, and stable error codes in responses so clients don't parse messages (Error Handling Patterns).
7. Configuration: typed, validated once, at startup
Configuration errors should stop the service at startup with a clear message, not surface as a panic an hour later. Load everything into a typed struct, validate it, and pass the parts each component needs.
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug)]
struct Config {
port: u16,
database_url: String,
db_pool_size: u32,
request_timeout: Duration,
payment_provider: PaymentProvider,
}
#[derive(Debug)]
enum PaymentProvider {
Stripe { api_key_set: bool },
Fake,
}
fn load(env: &HashMap<&str, &str>) -> Result<Config, Vec<String>> {
let mut errors = Vec::new();
let get = |key: &str| env.get(key).copied();
let port = get("PORT").unwrap_or("8080").parse::<u16>().map_err(|e| errors.push(format!("PORT: {e}"))).unwrap_or(0);
let database_url = get("DATABASE_URL").map(str::to_string).unwrap_or_else(|| {
errors.push("DATABASE_URL: required".into());
String::new()
});
let db_pool_size = match get("DB_POOL_SIZE").unwrap_or("10").parse::<u32>() {
Ok(n) if (1..=100).contains(&n) => n,
Ok(n) => {
errors.push(format!("DB_POOL_SIZE: {n} is outside 1..=100"));
0
}
Err(e) => {
errors.push(format!("DB_POOL_SIZE: {e}"));
0
}
};
let request_timeout = Duration::from_millis(get("REQUEST_TIMEOUT_MS").unwrap_or("5000").parse().unwrap_or_else(|_| {
errors.push("REQUEST_TIMEOUT_MS: not a number".into());
0
}));
let payment_provider = match get("PAYMENT_PROVIDER").unwrap_or("fake") {
"stripe" => PaymentProvider::Stripe { api_key_set: get("STRIPE_API_KEY").is_some() },
"fake" => PaymentProvider::Fake,
other => {
errors.push(format!("PAYMENT_PROVIDER: unknown value {other:?}"));
PaymentProvider::Fake
}
};
if let PaymentProvider::Stripe { api_key_set: false } = payment_provider {
errors.push("STRIPE_API_KEY: required when PAYMENT_PROVIDER=stripe".into());
}
if errors.is_empty() { Ok(Config { port, database_url, db_pool_size, request_timeout, payment_provider }) } else { Err(errors) }
}
fn main() {
let good = HashMap::from([("DATABASE_URL", "postgres://db/orders"), ("PAYMENT_PROVIDER", "stripe"), ("STRIPE_API_KEY", "sk_live_...")]);
println!("{:#?}", load(&good).unwrap());
let bad = HashMap::from([("PORT", "80800"), ("DB_POOL_SIZE", "500"), ("PAYMENT_PROVIDER", "stripe")]);
match load(&bad) {
Ok(_) => println!("unexpected"),
Err(errors) => {
println!("invalid configuration:");
for e in errors {
println!(" - {e}");
}
}
}
}Config {
port: 8080,
database_url: "postgres://db/orders",
db_pool_size: 10,
request_timeout: 5s,
payment_provider: Stripe {
api_key_set: true,
},
}
invalid configuration:
- PORT: number too large to fit in target type
- DATABASE_URL: required
- DB_POOL_SIZE: 500 is outside 1..=100
- STRIPE_API_KEY: required when PAYMENT_PROVIDER=stripeReporting all configuration errors at once saves a deploy-fix-redeploy cycle per mistake. In practice, crates such as figment or config merge files, environment variables, and defaults into a serde-deserialized struct; the validation step stays yours. Keep secrets out of Debug output (store them in a wrapper type whose Debug prints ***, or use the secrecy crate).
8. Application state and where locks live
Handlers receive shared state by cloning an AppState (cheap: it holds Arcs and pool handles). Keep mutable shared state rare and encapsulated: a connection pool, a cache, a rate limiter, each with its own internal synchronization, rather than one big Arc<Mutex<AppState>>.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::sync::atomic::{AtomicU64, Ordering};
/// A component with its own synchronization, exposing only safe operations.
#[derive(Default)]
struct PriceCache {
prices: RwLock<HashMap<String, u64>>,
hits: AtomicU64,
misses: AtomicU64,
}
impl PriceCache {
fn get_or_load(&self, sku: &str, load: impl FnOnce() -> u64) -> u64 {
if let Some(p) = self.prices.read().unwrap().get(sku) {
self.hits.fetch_add(1, Ordering::Relaxed);
return *p;
}
self.misses.fetch_add(1, Ordering::Relaxed);
let price = load(); // not holding the lock while loading
self.prices.write().unwrap().insert(sku.to_string(), price);
price
}
}
#[derive(Clone)]
struct AppState {
prices: Arc<PriceCache>,
service_name: Arc<str>, // immutable config: no lock needed
}
fn handle(state: &AppState, sku: &str) -> String {
let price = state.prices.get_or_load(sku, || 1999);
format!("[{}] {sku} costs {price}", state.service_name)
}
fn main() {
let state = AppState { prices: Arc::new(PriceCache::default()), service_name: Arc::from("catalog") };
let handles: Vec<_> = (0..4)
.map(|i| {
let state = state.clone(); // cheap: two Arc clones
std::thread::spawn(move || handle(&state, if i % 2 == 0 { "ABC-1234" } else { "XYZ-0001" }))
})
.collect();
for h in handles {
let _ = h.join().unwrap();
}
println!("{}", handle(&state, "ABC-1234"));
let (hits, misses) = (state.prices.hits.load(Ordering::Relaxed), state.prices.misses.load(Ordering::Relaxed));
println!("lookups: {}, misses at least 2: {}", hits + misses, misses >= 2);
}[catalog] ABC-1234 costs 1999
lookups: 5, misses at least 2: trueThe rule of thumb: immutable things (config, compiled regexes, templates) in Arc with no lock; each mutable component owns its lock and never exposes it; nothing holds a lock across .await or I/O (Async Pitfalls in Production).
9. Testing through the layers
The layering pays off in tests. Each layer is tested at its own level, and each kind of test stays fast:
| Test | What it exercises | Adapters used | Speed |
|---|---|---|---|
| Domain unit tests, property tests | types and rules | none | microseconds |
| Use-case tests | application logic and error handling | in-memory repositories, fixed clocks, fake gateways | microseconds |
| HTTP tests | routing, extraction, status codes, JSON shapes | the real router via oneshot, fake use cases or in-memory adapters |
milliseconds |
| Adapter integration tests | SQL, serialization, external protocols | a real database in a container (testcontainers, sqlx::test) |
seconds |
| End-to-end tests | the deployed service | everything real | slowest; keep few |
Examples 3 and 6 are the first three kinds already: PlaceOrder tested with InMemoryOrders and FixedClock, and the router called with oneshot without opening a socket.
10. Workspace layout for a larger service
When a service outgrows one crate, split along the layers so the compiler enforces the dependency direction, and builds get faster (Compile Times & Build Engineering):
orders-service/
├── Cargo.toml # [workspace], [workspace.dependencies], [workspace.lints]
├── crates/
│ ├── domain/ # types and rules; no async, no I/O dependencies
│ ├── app/ # use cases and port traits; depends on domain
│ ├── http/ # axum routes, DTOs, error mapping; depends on app
│ ├── postgres/ # repository implementations with sqlx; depends on app
│ ├── payments-stripe/ # payment gateway adapter; depends on app
│ └── test-support/ # in-memory adapters and builders shared by tests
└── bin/
└── orders/ # main.rs: config, telemetry, wiring, graceful shutdownIf domain/Cargo.toml doesn't list sqlx, no one can accidentally import it there. Keep the number of crates proportional to the team and codebase: three or four for most services, more only when build times or ownership boundaries call for it.
11. Anti-patterns, often imported from other languages
| Anti-pattern | Why it hurts | Instead |
|---|---|---|
One Arc<Mutex<AppState>> for everything |
serializes the service, invites locks across .await |
components with their own synchronization |
| A trait for every struct "for testability" | indirection, generic noise, no second implementation | traits only at real boundaries; test pure logic directly |
Deep generic stacks (Service<R, C, N, P, ...>) |
unreadable types, slow builds | Arc<dyn Trait> for adapters chosen at startup |
String errors or anyhow inside libraries and use cases |
callers can't match on failures | thiserror enums per layer |
unwrap() on request data |
one bad request panics a task or the service | validate at the boundary, return errors |
Domain types with serde attributes for the HTTP format |
the API format dictates the domain | separate DTOs, From/TryFrom at the edge |
Everything pub |
no encapsulation, every change is breaking | private fields, constructors, pub(crate) |
Global mutable state (static with Mutex) for convenience |
hidden coupling, hard to test | pass state explicitly through AppState |
| Cloning to silence the borrow checker everywhere | hides ownership design problems, costs allocations | rethink who owns what (Memory Without a Garbage Collector) |
Part 2: Leading a Rust team
12. Code review guidelines
Teams write down what reviewers check, so reviews are consistent and new reviewers know what to look for. Tools enforce what they can (formatting, lints, tests, cargo deny); humans review the rest:
| Area | Questions for the reviewer |
|---|---|
| Ownership and API | Do functions borrow what they only read? Are clone()s justified? Are types and fields as private as possible? |
| Errors | Are errors typed where callers need to match? Any unwrap/expect/indexing on external input? Are errors logged once, at the boundary? |
| Async | Any blocking calls, locks across .await, missing timeouts, unbounded channels, detached tasks? |
unsafe |
Is it necessary? Does every block have a // Safety: comment, and do tests run under Miri? |
| Dependencies | Is a new crate justified, maintained, license-compatible? Does it have a build script or proc macro? |
| Performance | Allocations or clones in hot paths? Generic code that could be dyn in cold paths? |
| Observability | Are new operations instrumented? Are secrets kept out of logs and spans? |
| Tests | Are error paths tested? Would a property test or snapshot fit better? |
Keep "the compiler would have caught it" out of reviews: rely on the compiler and Clippy, and spend human attention on design, naming, and the questions above.
13. Onboarding and growing Rust skills
Teams moving to Rust consistently report the same curve: people write working code within the first weeks, fight the borrow checker for a while, and then the design lessons (ownership, error types, where state lives) become instinct over a few months. Things that shorten the curve:
- A learning path. This course's order is one; "The Rust Programming Language" book and Rustlings exercises are the standard starting points.
- Pairing on the first real tasks, especially on borrow-checker errors: an experienced developer explains why, which teaches more than any workaround.
- A reference service in the team's style (layout, error handling, telemetry, tests) that new code copies instead of reinventing.
- Allow simple code first.
clone(),Stringinstead of&str, andArc<Mutex<_>>are fine in a first version; optimize ownership when profiling or review shows it matters. - Guided reviews. Reviewers explain idioms (iterators,
?,impl Trait) with links, instead of just rewriting code. - Internal libraries for cross-cutting concerns (telemetry setup, config loading, error responses), so each service doesn't solve them again.
14. When not to use Rust
Part of a lead's job is saying no. Rust is a poor fit, or at least not the obvious choice, when:
| Situation | Why | Often better |
|---|---|---|
| A CRUD service where the team is productive in its current stack and performance is not a problem | Rust's benefits (performance, memory efficiency, safety without GC) don't pay back the learning cost | the team's existing language |
| Rapid prototyping of product ideas that will change weekly | strict types slow down throwaway exploration | a dynamic language, then rewrite what survives |
| Heavy dependence on a library ecosystem that only exists elsewhere (some ML, BI, enterprise integrations) | reimplementing or binding costs more than it saves | the ecosystem's language, with Rust for the hot core if needed |
| A team with no one experienced in Rust and no time to learn | the first services will encode beginner designs | start with a small, well-bounded component and a mentor |
And where it shines: latency-sensitive and high-throughput services, infrastructure (proxies, databases, queues), CLIs, systems that must run for months without memory growth, anything parsing untrusted input, code that runs on constrained hardware, and libraries shared across languages through FFI or WebAssembly.
15. Topics for later: specialist areas
This course deliberately stops short of a few specialist areas. They matter a great deal to the teams that need them, and are candidates for future lessons:
| Topic | What it covers | Where to start today |
|---|---|---|
| SIMD | processing several values per instruction: std::arch intrinsics, portable SIMD (std::simd, nightly), crates such as wide and pulp |
the std::arch documentation; How the Rust Compiler Works for auto-vectorization |
| WebAssembly | compiling Rust to run in browsers and sandboxed runtimes: wasm-bindgen, wasm-pack, WASI, the component model, plugins with wasmtime |
the Rust and WebAssembly book; the wasm-bindgen guide |
Embedded beyond no_std |
microcontrollers: HALs and peripheral access crates, embedded-hal, interrupts, embassy for async on bare metal, probe-rs for flashing and debugging |
the Embedded Rust book; no_std for the foundations |
| GPU programming | compute and graphics on the GPU: wgpu, CUDA and Rust-GPU projects, shader languages |
the wgpu examples and learn-wgpu tutorial |
Summary
- Structure services as domain, application (use cases and ports), and adapters, with dependencies pointing inward; enforce it with crate boundaries when the service grows.
- Make invalid domain states unrepresentable; convert at the edges.
- Ports are traits at real boundaries. Use generics for small hot abstractions and
Arc<dyn Trait>for adapters chosen at startup;#[async_trait]makes async traits dyn-compatible. - Wire dependencies explicitly in
main; map errors to responses in one place; load and validate typed configuration at startup, reporting all problems at once. - Keep shared mutable state in small components with their own synchronization; test each layer at its own level.
- As a lead: write down review guidelines, support people through the learning curve, choose Rust where it pays off, and add specialist areas (SIMD, WebAssembly, embedded, GPU) when a team needs them.
This completes the Production Rust section and the course. To keep going, the Essential Crates course covers the libraries almost every Rust service uses, starting with Serialization with serde, and the Practical Examples course builds twelve complete programs.
