Part 4: Dependency Injection

Part 2 gave you static JSON. Part 3 gave you a database table. Now let's connect them.

This is the central chapter of the REST tutorial. Reinhardt handlers are plain async functions, but they can ask the framework for dependencies with #[inject]. We will inject a DatabaseConnection, use the ORM manager, and add a small configuration endpoint that shows how provider return values are keyed in the DI registry.

Add the Support Serializer Module

The real handlers use SnippetSerializer for input and SnippetResponse for output. Create src/apps/snippets/serializers.rs now; Part 5 explains the validation rules in detail.

use reinhardt::Validate;
use serde::{Deserialize, Serialize};

/// Serializer for creating/updating snippets
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct SnippetSerializer {
	#[validate(length(
		min = 1,
		max = 100,
		message = "Title must be between 1 and 100 characters"
	))]
	pub title: String,

	#[validate(length(
		min = 1,
		max = 10000,
		message = "Code must be between 1 and 10000 characters"
	))]
	pub code: String,

	#[validate(length(
		min = 1,
		max = 50,
		message = "Language must be between 1 and 50 characters"
	))]
	pub language: String,
}

/// Response serializer for snippets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnippetResponse {
	pub id: i64,
	pub title: String,
	pub code: String,
	pub language: String,
	pub highlighted: String,
}

impl SnippetResponse {
	pub fn from_model(snippet: &super::models::Snippet) -> Self {
		Self {
			id: snippet.id,
			title: snippet.title.clone(),
			code: snippet.code.clone(),
			language: snippet.language.clone(),
			highlighted: snippet.highlighted(),
		}
	}
}

This is supporting code for the DI work below. The key point for now is that handlers can receive a parsed Json<SnippetSerializer> and can turn database models into JSON-friendly SnippetResponse values.

Register a DI Module

Open src/apps/snippets.rs and make sure it declares the new module:

use reinhardt::app_config;

pub mod di;
pub mod models;
pub mod serializers;
pub mod urls;
pub mod views;

#[app_config(name = "snippets", label = "snippets")]
pub struct SnippetsConfig;

Now create src/apps/snippets/di.rs:

use reinhardt::di::{Depends, FactoryOutput, injectable, injectable_key};

/// Snippet listing configuration resolved through DI.
pub struct SnippetListConfig {
	pub max_page_size: usize,
}

impl Default for SnippetListConfig {
	fn default() -> Self {
		Self { max_page_size: 50 }
	}
}

#[injectable_key]
pub struct SnippetListConfigKey;

#[injectable(scope = "singleton")]
async fn snippet_list_config() -> FactoryOutput<SnippetListConfigKey, SnippetListConfig> {
	FactoryOutput::new(SnippetListConfig::default())
}

/// Error type local to `checked_list_config`.
#[derive(Debug)]
pub struct ConfigError(pub String);

#[injectable_key]
pub struct CheckedSnippetListConfigKey;

/// Fallible variant of `SnippetListConfig`, registered under the
/// `CheckedSnippetListConfigKey` key.
#[injectable(scope = "singleton")]
async fn checked_list_config(
	#[inject] base: Depends<SnippetListConfigKey, SnippetListConfig>,
) -> FactoryOutput<CheckedSnippetListConfigKey, Result<SnippetListConfig, ConfigError>> {
	if base.max_page_size == 0 {
		return FactoryOutput::new(Err(ConfigError("max_page_size must be positive".into())));
	}
	FactoryOutput::new(Ok(SnippetListConfig {
		max_page_size: base.max_page_size,
	}))
}

#[injectable] can register provider functions as well as structs. In the provider form, return FactoryOutput<K, T>: K is the registry key, and T is the value resolved by Depends<K, T>.

Provider functions can be async and can depend on other dependencies through #[inject] parameters. Here, checked_list_config asks for the plain config and returns a checked Result under a different key.

Understand Scopes and Caching

The DI macro accepts a scope:

#[injectable(scope = "singleton")]
#[injectable(scope = "request")]
#[injectable(scope = "transient")]

Use them this way:

  • singleton: build once and reuse. Good for immutable configuration.
  • request: build once per request. Good for request-local context.
  • transient: build every time it is resolved. Good for short-lived values that need unique mutable ownership.

Within one resolution path, dependencies are cached by default according to their scope. If you need a fresh value even when a cached value exists, write the injection as:

#[inject(cache = false)] fresh: Depends<MyDependencyKey, MyDependency>

Most handlers should not need cache = false. It is an escape hatch, not the normal style.

Know What the Registry Key Is

This is the part that saves you from confusing DI bugs.

Reinhardt keys each provider by the explicit key type in FactoryOutput<K, T>. Two providers can return the same value type as long as they use different key types.

When you need a second flavor of the same success value, introduce a second key:

pub struct ConfigError(pub String);

#[injectable_key]
pub struct CheckedSnippetListConfigKey;

#[injectable(scope = "singleton")]
async fn checked_list_config(
	#[inject] base: Depends<SnippetListConfigKey, SnippetListConfig>,
) -> FactoryOutput<CheckedSnippetListConfigKey, Result<SnippetListConfig, ConfigError>> {
	/* ... */
}

The plain config is registered under SnippetListConfigKey. The checked config is registered under CheckedSnippetListConfigKey and resolves as Depends<CheckedSnippetListConfigKey, Result<SnippetListConfig, ConfigError>>.

Respect the Pseudo Orphan Rule

Reinhardt protects framework-owned dependency types from project-local overrides. Types whose Rust path starts with framework prefixes such as reinhardt:: or reinhardt_*:: are treated as framework-managed.

That is why generated project and app names cannot start with reinhardt_ or reinhardt-. Use names such as tutorial, snippets, or your product name. Do not create project-local crates or modules that pretend to be framework namespaces.

Rewrite the Handlers

Replace src/apps/snippets/views.rs with the real database-backed version.

Start with the imports:

use json::json;
use reinhardt::Validate;
use reinhardt::core::serde::json;
use reinhardt::db::DatabaseConnection;
use reinhardt::db::orm::Manager;
use reinhardt::di::Depends;
use reinhardt::http::ViewResult;
use reinhardt::{Json, Path, Response, StatusCode};
use reinhardt::{delete, get, post, put};

use super::di::{CheckedSnippetListConfigKey, ConfigError, SnippetListConfig};
use super::models::Snippet;
use super::serializers::{SnippetResponse, SnippetSerializer};

The handlers will not create connections by hand. They ask the route macro to inject one:

#[get("/snippets/", name = "snippets-list")]
pub async fn list(#[inject] db: DatabaseConnection) -> ViewResult<Response> {
	let snippets = Manager::<Snippet>::new().all().all_with_db(&db).await?;
	let snippet_responses: Vec<SnippetResponse> =
		snippets.iter().map(SnippetResponse::from_model).collect();

	let response_data = json!({ "snippets": snippet_responses });
	let json = json::to_string(&response_data)?;
	Ok(Response::new(StatusCode::OK)
		.with_header("Content-Type", "application/json")
		.with_body(json))
}

The framework-owned DatabaseConnection is injected directly. Use keyed Depends<K, T> for project-local providers where the key matters.

Now replace create with a real insert:

#[post("/snippets/", name = "snippets-create", pre_validate = true)]
pub async fn create(
	Json(serializer): Json<SnippetSerializer>,
	#[inject] db: DatabaseConnection,
) -> ViewResult<Response> {
	let snippet = Snippet::build()
		.title(serializer.title.clone())
		.code(serializer.code.clone())
		.language(serializer.language.clone())
		.finish();

	let created = Manager::<Snippet>::new()
		.create_with_conn(&db, &snippet)
		.await?;

	let response_data = json!({
		"message": "Snippet created",
		"snippet": SnippetResponse::from_model(&created)
	});

	let json = json::to_string(&response_data)?;
	Ok(Response::new(StatusCode::CREATED)
		.with_header("Content-Type", "application/json")
		.with_body(json))
}

The handler receives the JSON body and the database connection in the same function signature. The route macro extracts the body; the DI runtime resolves the connection.

Add retrieve:

#[get("/snippets/{id}/", name = "snippets-retrieve")]
pub async fn retrieve(
	Path(snippet_id): Path<i64>,
	#[inject] db: DatabaseConnection,
) -> ViewResult<Response> {
	let snippets = Manager::<Snippet>::new()
		.get(snippet_id)
		.all_with_db(&db)
		.await?;

	let snippet = match snippets.first() {
		Some(snippet) => snippet,
		None => {
			let error = json::to_string(&json!({"error": "Snippet not found"}))?;
			return Ok(Response::new(StatusCode::NOT_FOUND)
				.with_header("Content-Type", "application/json")
				.with_body(error));
		}
	};

	let response_data = json!({
		"snippet": SnippetResponse::from_model(snippet)
	});

	let json = json::to_string(&response_data)?;
	Ok(Response::new(StatusCode::OK)
		.with_header("Content-Type", "application/json")
		.with_body(json))
}

Add update:

#[put("/snippets/{id}/", name = "snippets-update")]
pub async fn update(
	Path(snippet_id): Path<i64>,
	Json(serializer): Json<SnippetSerializer>,
	#[inject] db: DatabaseConnection,
) -> ViewResult<Response> {
	serializer.validate()?;

	let manager = Manager::<Snippet>::new();
	let existing = manager.get(snippet_id).all_with_db(&db).await?;

	let mut snippet = match existing.into_iter().next() {
		Some(snippet) => snippet,
		None => {
			let error = json::to_string(&json!({"error": "Snippet not found"}))?;
			return Ok(Response::new(StatusCode::NOT_FOUND)
				.with_header("Content-Type", "application/json")
				.with_body(error));
		}
	};

	snippet.title = serializer.title.clone();
	snippet.code = serializer.code.clone();
	snippet.language = serializer.language.clone();

	let updated = manager.update_with_conn(&db, &snippet).await?;

	let response_data = json!({
		"message": "Snippet updated",
		"snippet": SnippetResponse::from_model(&updated)
	});

	let json = json::to_string(&response_data)?;
	Ok(Response::new(StatusCode::OK)
		.with_header("Content-Type", "application/json")
		.with_body(json))
}

We validate manually here because this handler mixes Path<i64> and Json<SnippetSerializer>. Part 5 explains the pre_validate = true limitation that makes this necessary.

Add delete:

#[delete("/snippets/{id}/", name = "snippets-delete")]
pub async fn delete(
	Path(snippet_id): Path<i64>,
	#[inject] db: DatabaseConnection,
) -> ViewResult<Response> {
	let manager = Manager::<Snippet>::new();
	let existing = manager.get(snippet_id).all_with_db(&db).await?;

	if existing.is_empty() {
		let error = json::to_string(&json!({"error": "Snippet not found"}))?;
		return Ok(Response::new(StatusCode::NOT_FOUND)
			.with_header("Content-Type", "application/json")
			.with_body(error));
	}

	manager.delete_with_conn(&db, snippet_id).await?;

	Ok(Response::new(StatusCode::NO_CONTENT))
}

Add the Config Endpoint

Now add the endpoint that exercises the fallible provider:

#[get("/snippets/config/", name = "snippets-config")]
pub async fn config(
	#[inject] cfg: Depends<CheckedSnippetListConfigKey, Result<SnippetListConfig, ConfigError>>,
) -> ViewResult<Response> {
	match (*cfg).as_ref() {
		Ok(cfg) => {
			let body = json::to_string(&json!({ "max_page_size": cfg.max_page_size }))?;
			Ok(Response::new(StatusCode::OK)
				.with_header("Content-Type", "application/json")
				.with_body(body))
		}
		Err(ConfigError(msg)) => {
			let body = json::to_string(&json!({ "error": msg }))?;
			Ok(Response::new(StatusCode::SERVICE_UNAVAILABLE)
				.with_header("Content-Type", "application/json")
				.with_body(body))
		}
	}
}

The key in Depends<CheckedSnippetListConfigKey, Result<SnippetListConfig, ConfigError>> selects the checked provider.

The match uses (*cfg).as_ref(). *cfg dereferences Depends<_, _> into the inner Result<_, _>, and .as_ref() lets the handler inspect Ok(&SnippetListConfig) or Err(&ConfigError) without consuming the dependency wrapper.

Register the Routes

Update src/apps/snippets/urls.rs:

use reinhardt::ServerRouter;

use super::views;

pub fn url_patterns() -> ServerRouter {
	ServerRouter::new()
		.endpoint(views::list)
		.endpoint(views::create)
		.endpoint(views::config)
		.endpoint(views::retrieve)
		.endpoint(views::update)
		.endpoint(views::delete)
}

config is registered before retrieve. That matters because /snippets/config/ is a literal path, while /snippets/{id}/ is dynamic. The literal route should get the first chance to match.

Run It

Apply the migration and start the server:

cargo make migrate
cargo make runserver

In another terminal, list snippets:

curl http://127.0.0.1:8000/api/snippets/

On a clean database, you should see:

{"snippets":[]}

Create one:

curl -X POST http://127.0.0.1:8000/api/snippets/ \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Hello World",
    "code": "println!(\"Hello, world!\");",
    "language": "rust"
  }'

You should see 201 Created with the created snippet. Now check the DI demonstration endpoint:

curl http://127.0.0.1:8000/api/snippets/config/

Expected response:

{"max_page_size":50}

Finally, run a compile check:

cargo check --all-features

What You Built

You replaced the temporary Part 2 handlers with real database-backed CRUD:

  • CRUD handlers receive #[inject] db: DatabaseConnection.
  • Project-local providers resolve through keyed Depends<K, T>, so (*cfg).as_ref() is the tool you need for fallible dependencies.
  • Manager::<Snippet> queries, inserts, updates, and deletes rows through the injected connection.
  • di.rs registers plain and fallible providers with #[injectable] and FactoryOutput<K, T>.
  • Key types such as SnippetListConfigKey and CheckedSnippetListConfigKey select which provider to resolve.

In Part 5: Serializers and Validation, you will focus on the request and response structs we used here and make the validation behavior explicit.