Skip to main content

Dependency Injection

Dependency injection is a way to decouple your structures from their dependencies. It allows you to replace the components that your system needs with alternative implementations for different situations.

For example, a Controller may want to interact with a Repository to access information in persistent storage. In different situations you may want a Postgres Repository, a DynamoDB Repository, or an In-Memory Repository. Using dependency injection for loose coupling allows your Controller to depend on a common Repository trait that they all implement, without caring which underlying implementation actually fulfills the requirement.

This is accomplished using Any from Rust's standard library. By using dynamic typing, you can easily swap between different implementations for different entry points or contexts, and easily add more as needed for any situation.

One quirk of Any is that values need to have the 'static lifetime, meaning they are valid until program execution ends if they are not dropped. Keep this in mind if you're frequently injecting and removing items from the container during your program's lifecycle.

Async

Nakago's Inject framework is built on Tokio with Shared Futures, allowing multiple threads to request and await the same dependency and use an Arc to hold on to it across await points without worrying about lifetimes.

It uses Providers that implement the async Provider trait and use the provider's instance for configuration or context, and the Inject container to request other dependencies you require. Providers are lazily invoked - they are stored internally but not invoked until they are requested. They are then converted into a pending Shared Future that can be polled by multiple threads at the same time. This allows multiple resources to wait for the same Provider invocation without duplicaiton.

Providers don't have to be injected in any order - they will wait inside the container until they have been requested, so you have some flexibility in your application's initialization process. They are guarded by RwLock wrappers for thread-safe access, and the locks are released once the Arc<T> is yielded.

Usage

First, let's establish a hypothetical Entity and a trait that defines a method to retrieve it from persistent storage:

use async_trait::async_trait;

struct Entity {
id: String,
}

#[async_trait]
trait Repository: Sync + Send {
async fn get(&self, id: &str) -> Option<Entity>;
}

Then a hypothetical Postgres implementation:

use sqlx::{Pool, Postgres};

struct PostgresRepository {
pool: Pool<Postgres>
}

#[async_trait]
impl Repository for PostgresRepository {
async fn get(&self, id: &str) -> Option<Entity> {
// ...
}
}

And an alternate DynamoDB implementation:

use aws_sdk_dynamodb::Client;

struct DynamoRepository {
client: Client,
}

#[async_trait]
impl Repository for DynamoRepository {
async fn get(&self, id: &str) -> Option<Entity> {
// ...
}
}

Dependency Tags

The injection framework can work directly with the TypeId identifiers that are automatically generated by the any package, but they often require you to pass in type parameters and repeat yourself more often, and they result in debug output that can be rather verbose:

nakago::inject::container::test::entity::DynamoRepository was not found

Available:
- std::boxed::Box<dyn nakago::inject::container::test::entity::Repository>

- nakago::inject::container::test::entity::PostgresRepository

Tags carry the underlying type around with them, meaning it can be inferred by the compiler in most cases. They also allow you to inject multiple instances of the same type, with different keys. If you have multiple Database Configs, for example, you can inject them into the container with separate tags even though they contain the same type.

pub const POSTGRES_REPO: Tag<PostgresRepository> = Tag::new("entity::PostgresRepository");
pub const DYNAMO_REPO: Tag<DynamoRepository> = Tag::new("entity::DynamoRepository");
pub const REPO: Tag<Box<dyn Repository>> = Tag::new("entity::Repository");

Instead of requesting the type explicitly like this:

let result = i.get_type::<PostgresRepository>()?;

Tags are passed in and the type is inferred:

let result = i.get(&POSTGRES_REPO)?;

Tags have a special String value that can be used instead of the full type name. This makes it easier to understand debug output, and this is what allows multiple versions of the same type to have different keys.

Tag(DynamoEntityRepository) was not found

Available:
- Tag(EntityRepository)

- Tag(PostgresEntityRepository)

Providing Dependencies

To provide a dependency, create a Provider that implements the inject::Provider trait:

use async_trait::async_trait;
use nakago::inject::{inject, Provider, Inject};
use sqlx::{Pool, Postgres};

#[derive(Default)]
pub struct PostgresRepositoryProvider {}

#[Provider]
#[async_trait]
impl Provider<Box<dyn Repository>> for PostgresRepositoryProvider {
async fn provide(self: Arc<Self>, i: Inject) -> provider::Result<Arc<Box<dyn Repository>>> {
let pool = i.get_type::<Pool<Postgres>>().await?;

Ok(Arc::new(Box::new(PostgresRepository::new(pool.clone()))))
}
}

The PostgresRepositoryProvider struct is empty, and just exists so that we can implement the Provider<T> trait. It uses #[derive(Default)] because it doesn't need to initialize any config properties or context. It doesn't have to be empty, though, and can carry information for the provider that is passed in on initialization and held until the Provider is invoked.

The result is wrapped in an provider::Result so that an Err can be returned to handle things like a failed i.get() call or a failed database connection initialization.

In this particular case since Pool<Postgres> is a known Sized type, it's safe to provide it without Boxing it to handle Unsized dynamic trait implementations. In many cases, however, you'll be working with dyn Trait implementations so that you can swap between implementations easily. You'll want to make sure to box it up like Box<dyn Trait> so that it can later be wrapped in the Shared Future and held across await points.

You don't need to worry about using a Tag with a Provider yet - that comes in the next step.

The Provider Macro

You may have noticed the #[Provider] macro above. This macro provides a companion implementation for the impl Provider<Box<dyn Repository>> above that provides impl Provider<Dependency> instead. This is so that your Provider that carries a specific type T can also provide the generic version of that Dependency that is dyn Any + Send + Sync that the container needs to keep it in the same HashMap as all the other dependencies.

If you want to provide it manually instead, you can:

#[async_trait]
impl Provider<Dependency> for PostgresRepositoryProvider {
async fn provide(self: Arc<Self>, i: Inject) -> provider::Result<Arc<Dependency>> {
let provider = self as Arc<dyn Provider<Box<dyn Repository>>>;

Ok(provider.provide(i).await?)
}
}

The Inject Container

To make use of these Providers, create a dependency injection container instance:

let i = Inject::default();

This is typically done at an entry point to your application, such as a main.go file or within a unit or integration test setup routine.

Now, use i.provide(...).await? to inject the Provider and associate it with a Tag:

i.provide(&REPO, PostgresRepositoryProvider::default()).await?;

I'll pause to point out here that if you had tried to use the &POSTGRES_REPO Tag here, the compiler would report an error because the PostgresRepositoryProvider above provides Box<dyn Repository>, not PostgresRepository.

Invoking Dependencies

To pull dependencies out of the container, use i.get(&TAG).await? or i.get_type::<T>().await?. If a dependency isn't available, the container will return an inject::Error::NotFound result. This is often performed within a provide function from the Provider trait, but it is also used often at entry points to bootstrap an application.

let repo = i.get(&TAG).await?;

You can use i.get_opt(&TAG).await? to receive an Option rather than a Result.

let maybe_repo = i.get_opt(&TAG).await?;

Consuming Dependencies

In some cases, such as with Config Loaders, a dependency is intended to be used up and made unavailable afterwards. This is often done within Lifecycle Hooks.

let loaders = i.consume(&CONFIG_LOADERS).await?;

let loader = Loader::<C>::new(loaders);

In this example if any providers have been injected for the &CONFIG_LOADERS tag they are requested, awaited, and then pulled out of the container and the tag is removed. If you try to i.get() or i.consume() the &CONFIG_LOADERS tag again, you will receive the inject::Error::NotFound Error.

Ejection

In certain contexts, such as testing, it's useful to drop the entire container except for a particular dependency - like a MockDatabaseConnection used for validating expectations in unit testing.

let db = i.eject(&DATABASE_CONNECTION).await?;

You can then perform the mutable operations you need for validating assertions in testing:

// Check the transaction log
assert_eq!(
db.into_transaction_log(),
vec![Transaction::from_sql_and_values(
DatabaseBackend::Postgres,
r#"SELECT "episodes"."id", "episodes"."created_at", "episodes"."updated_at", "episodes"."title", "episodes"."summary", "episodes"."picture", "episodes"."show_id" FROM "episodes" WHERE "episodes"."id" = $1 LIMIT $2"#,
vec![episode.id.into(), 1u64.into()]
)]
);