Skip to content

contextgeneric/cgp-example-profile-picture

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cgp-example-profile-picture

A step-by-step tutorial demonstrating how to evolve a real-world Rust application from a single monolithic function all the way to a fully modular, context-generic design using CGP — Context-Generic Programming.

This demo was created for the Rust Berlin meetup in March 2026 to introduce beginners to CGP and the new #[cgp_fn] and #[cgp_impl] macros added in CGP v0.7.0. Link to presentation

Disclaimer

This readme file is generated by Claude using the given prompt.

What Is CGP?

Context-Generic Programming (CGP) is a Rust library and programming paradigm that makes it possible to write modular, reusable code that works across many different concrete types — without the usual friction that Rust's trait coherence rules impose. The key idea is that CGP lets you write functions and trait implementations that depend only on the capabilities a context provides, rather than on any single concrete type.

If you have never heard of CGP before, you are in the right place. This tutorial is designed for Rust programmers who are encountering CGP for the very first time.

The Running Example

Throughout this tutorial we will implement a single business operation: fetching a user's profile picture. The operation involves two infrastructure concerns:

  1. Looking up a User row from a PostgreSQL database by their UserId.
  2. If the user has a profile picture, downloading the image bytes from an object storage service (such as Amazon S3) and decoding them into an in-memory RgbImage.

The domain types are defined in src/types.rs:

pub struct UserId(pub u64);

#[derive(sqlx::FromRow)]
pub struct User {
    pub name: String,
    pub email: String,
    pub profile_picture_object_id: Option<String>,
}

UserId is a thin newtype wrapper around a u64, and User is a straightforward struct that sqlx can populate directly from a database row. A user either has a profile_picture_object_id that names an object in storage, or they do not.

Each stage of the tutorial lives in its own source file. You can read them in order to follow the progression, or jump directly to the chapter that interests you most. Every stage compiles with the same types.rs definition above.


Chapter 1 — The Monolithic Function

Source file: src/monolithic_fn.rs

Writing the first version

The most natural place to start is a plain, self-contained async function that performs every step of the operation in a single pass:

use aws_sdk_s3::Client;
use image::RgbImage;
use sqlx::PgPool;

use crate::types::{User, UserId};

pub async fn get_user_profile_picture(
    database: &PgPool,
    storage_client: &Client,
    bucket_id: &str,
    user_id: &UserId,
) -> anyhow::Result<Option<RgbImage>> {
    let user: User =
        sqlx::query_as("SELECT name, email, profile_picture_object_id FROM users WHERE id = $1")
            .bind(user_id.0 as i64)
            .fetch_one(database)
            .await?;

    if let Some(object_id) = user.profile_picture_object_id {
        let output = storage_client
            .get_object()
            .bucket(bucket_id)
            .key(object_id)
            .send()
            .await?;

        let data = output.body.collect().await?.into_bytes().to_vec();

        let image = image::load_from_memory(&data)?.to_rgb8();

        Ok(Some(image))
    } else {
        Ok(None)
    }
}

This function is easy to understand at a glance. It accepts every dependency as an explicit argument — the database pool, the storage client, the bucket name, and the user identifier — performs the database query, conditionally fetches the object, decodes the image, and returns it.

What is good about this approach

The monolithic function has real virtues. It is entirely self-contained: a reader can understand exactly what happens by reading a single block of code from top to bottom without ever needing to look at another file. There are no abstractions to learn, no indirection to follow, and no surprises hidden inside helper types. For a quickly written prototype or a script that will never be reused, this level of simplicity is genuinely appropriate.

Where the problems start

As soon as the application grows beyond a single operation, the monolithic approach starts to show its cracks. The most immediate problem is parameter proliferation. Every caller of get_user_profile_picture must supply all four arguments — database, storage_client, bucket_id, and user_id — even if the caller itself does not need to know anything about databases or storage. As the call chain grows deeper, these arguments must be threaded through every layer of the stack, turning function signatures into unwieldy lists of dependencies.

A deeper problem is testability and reuse. Because the function hard-codes every step — the SQL query, the S3 API calls, and the image decoding — there is no way to swap out any of those steps in isolation. If you want to test the orchestration logic (the if let Some(object_id) branching) without hitting a real database, you cannot, because the database query is baked in.

Finally, the function is all-or-nothing. The database query step and the S3 fetch step are coupled together in a single function body. If a different part of the application needs to look up a user without ever touching object storage, or if it needs to fetch a storage object without first querying a database, it has to duplicate the relevant code.

The next chapter addresses the duplication problem by splitting the function into smaller, named pieces.


Chapter 2 — Breaking Into Plain Functions

Source file: src/plain_fn.rs

Extracting helper functions

The first refactoring step is to extract the two distinct sub-tasks — the database query and the object fetch — into their own named functions:

use aws_sdk_s3::Client;
use image::RgbImage;
use sqlx::PgPool;

use crate::types::{User, UserId};

pub async fn get_user(database: &PgPool, user_id: &UserId) -> anyhow::Result<User> {
    let user =
        sqlx::query_as("SELECT name, email, profile_picture_object_id FROM users WHERE id = $1")
            .bind(user_id.0 as i64)
            .fetch_one(database)
            .await?;

    Ok(user)
}

pub async fn fetch_storage_object(
    storage_client: &Client,
    bucket_id: &str,
    object_id: &str,
) -> anyhow::Result<Vec<u8>> {
    let output = storage_client
        .get_object()
        .bucket(bucket_id)
        .key(object_id)
        .send()
        .await?;

    let data = output.body.collect().await?.into_bytes().to_vec();
    Ok(data)
}

pub async fn get_user_profile_picture(
    database: &PgPool,
    storage_client: &Client,
    bucket_id: &str,
    user_id: &UserId,
) -> anyhow::Result<Option<RgbImage>> {
    let user = get_user(database, user_id).await?;

    if let Some(object_id) = user.profile_picture_object_id {
        let data = fetch_storage_object(storage_client, bucket_id, &object_id).await?;

        let image = image::load_from_memory(&data)?.to_rgb8();

        Ok(Some(image))
    } else {
        Ok(None)
    }
}

get_user_profile_picture now delegates to get_user and fetch_storage_object, and those two helpers can be called independently wherever they are needed.

What this improves

Extracting helpers is an improvement in several respects. The get_user logic is now named and findable: callers that only need a user record no longer have to duplicate the SQL query. Read separately, each function communicates a single, coherent intent, and a future maintainer does not have to read the whole composition to understand one piece of it.

What remains problematic

The parameter proliferation problem is not solved — it is worsened. get_user_profile_picture still carries four arguments, and every caller must still supply all of them. Meanwhile fetch_storage_object carries three of its own. As more helper functions are introduced (imagine an upload_profile_picture, delete_user, or send_email function), every function in the module ends up with a long, overlapping list of infrastructure arguments that all flow from the same sources.

Furthermore, a caller of get_user_profile_picture needs to know about PgPool, Client, and bucket_id even if it is a high-level orchestration function that really only cares about the business outcome. The low-level infrastructure details leak upward through every layer of the call stack.

The next chapter addresses this by grouping the shared dependencies into a single context value.


Chapter 3 — Methods on a Concrete Context

Source file: src/monolithic_context.rs

Grouping dependencies into a struct

The natural Rust solution to parameter proliferation is to collect all shared dependencies into a single struct and implement the functions as methods on it. The App context used in this chapter is defined in src/contexts/app.rs:

use aws_sdk_s3::Client;
use cgp::prelude::*;
use sqlx::PgPool;

#[derive(HasField)]
pub struct App {
    pub database: PgPool,
    pub storage_client: Client,
    pub bucket_id: String,
}

With App in hand, the three functions from the previous chapter become methods that accept only &self and the truly function-specific parameters:

use image::RgbImage;

use crate::contexts::app::App;
use crate::types::{User, UserId};

impl App {
    pub async fn get_user(&self, user_id: &UserId) -> anyhow::Result<User> {
        let user = sqlx::query_as(
            "SELECT name, email, profile_picture_object_id FROM users WHERE id = $1",
        )
        .bind(user_id.0 as i64)
        .fetch_one(&self.database)
        .await?;

        Ok(user)
    }

    pub async fn fetch_storage_object(&self, object_id: &str) -> anyhow::Result<Vec<u8>> {
        let output = self
            .storage_client
            .get_object()
            .bucket(&self.bucket_id)
            .key(object_id)
            .send()
            .await?;

        let data = output.body.collect().await?.into_bytes().to_vec();
        Ok(data)
    }

    pub async fn get_user_profile_picture(
        &self,
        user_id: &UserId,
    ) -> anyhow::Result<Option<RgbImage>> {
        let user = self.get_user(user_id).await?;

        if let Some(object_id) = user.profile_picture_object_id {
            let data = self.fetch_storage_object(&object_id).await?;
            let image = image::load_from_memory(&data)?.to_rgb8();

            Ok(Some(image))
        } else {
            Ok(None)
        }
    }
}

The method signatures are now clean and minimal. get_user_profile_picture only declares user_id as an argument, and get_user_profile_picture calls self.get_user(user_id) without needing to know which fields get_user reads from self.

What this improves

Grouping infrastructure into a single context is a dramatic improvement over passing individual arguments. Callers only need to hold a reference to App and pass the single function-specific argument. Adding a new method to App does not force all existing callers to change their signatures.

Why this is still limiting

The improvement comes at the cost of tight coupling. Every method in the impl App block is permanently bound to the concrete App type. This creates several practical problems.

Suppose that you want to write a minimal test context that contains only a database pool and nothing else, because you want unit-test get_user without setting up a storage client. With impl App, this is not possible: App always carries storage_client and bucket_id, and you cannot call self.get_user(user_id) on anything that isn't an App.

Suppose that the business wants to offer an embedded version of the application that uses SQLite instead of PostgreSQL. You would have to define a brand-new EmbeddedApp struct and copy-paste the entire impl block, since the methods are bound to a specific type.

Suppose that a teammate wants to add an AI-powered feature and stores an OpenAI client alongside the existing fields in a SmartApp. Now SmartApp cannot reuse get_user or fetch_storage_object because those methods are defined on App, not on SmartApp.

In all of these cases the problem is the same: the implementation of the functions is locked to a single concrete type. The next chapter breaks that lock by introducing traits.


Chapter 4 — Context-Generic Functions with Traits

Source file: src/monolithic_trait.rs

Making functions generic over any context

The standard Rust way to make functions work across multiple types is to introduce traits that describe the interface a type must provide. In this chapter we define AppFields — a trait that describes a context which carries a database pool, a storage client, and a bucket identifier:

use aws_sdk_s3::Client;
use image::RgbImage;
use rig::agent::Agent;
use rig::providers::openai;
use sqlx::PgPool;

use crate::contexts::app::App;
use crate::contexts::smart::SmartApp;
use crate::types::{User, UserId};

pub trait AppFields {
    fn database(&self) -> &PgPool;
    fn storage_client(&self) -> &Client;
    fn bucket_id(&self) -> &str;
}

pub trait SmartAppFields: AppFields {
    fn open_ai_agent(&self) -> &Agent<openai::CompletionModel>;

    fn open_ai_client(&self) -> &openai::Client;
}

The three functions are now generic over any Context: AppFields:

pub async fn get_user<Context: AppFields>(
    context: &Context,
    user_id: &UserId,
) -> anyhow::Result<User> {
    let user =
        sqlx::query_as("SELECT name, email, profile_picture_object_id FROM users WHERE id = $1")
            .bind(user_id.0 as i64)
            .fetch_one(context.database())
            .await?;

    Ok(user)
}

pub async fn fetch_storage_object<Context: AppFields>(
    context: &Context,
    object_id: &str,
) -> anyhow::Result<Vec<u8>> {
    let output = context
        .storage_client()
        .get_object()
        .bucket(context.bucket_id())
        .key(object_id)
        .send()
        .await?;

    let data = output.body.collect().await?.into_bytes().to_vec();
    Ok(data)
}

pub async fn get_user_profile_picture<Context: AppFields>(
    context: &Context,
    user_id: &UserId,
) -> anyhow::Result<Option<RgbImage>> {
    let user = get_user(context, user_id).await?;

    if let Some(object_id) = user.profile_picture_object_id {
        let data = fetch_storage_object(context, &object_id).await?;
        let image = image::load_from_memory(&data)?.to_rgb8();

        Ok(Some(image))
    } else {
        Ok(None)
    }
}

To make App and SmartApp work with these functions, each context only needs a straightforward trait implementation:

impl AppFields for App {
    fn database(&self) -> &PgPool {
        &self.database
    }

    fn storage_client(&self) -> &Client {
        &self.storage_client
    }

    fn bucket_id(&self) -> &str {
        &self.bucket_id
    }
}

impl AppFields for SmartApp {
    fn database(&self) -> &PgPool {
        &self.database
    }

    fn storage_client(&self) -> &Client {
        &self.storage_client
    }

    fn bucket_id(&self) -> &str {
        &self.bucket_id
    }
}

impl SmartAppFields for SmartApp {
    fn open_ai_agent(&self) -> &Agent<openai::CompletionModel> {
        &self.open_ai_agent
    }

    fn open_ai_client(&self) -> &openai::Client {
        &self.open_ai_client
    }
}

Notice that src/contexts/smart.rs defines SmartApp with additional OpenAI fields:

#[derive(HasField)]
pub struct SmartApp {
    pub database: PgPool,
    pub storage_client: Client,
    pub bucket_id: String,
    pub open_ai_client: openai::Client,
    pub open_ai_agent: Agent<openai::CompletionModel>,
}

Because SmartApp implements AppFields, it can call all three functions even though those functions know nothing about OpenAI. The extra fields are simply invisible to them.

What this improves

The trait approach eliminates the tight coupling between functions and a single concrete type. You can now define as many context types as you like — App, SmartApp, a test double, or an embedded variant — and each one only needs to implement AppFields to gain access to all three functions. Adding a new field to SmartApp does not affect App or any other context.

The remaining friction

Even with traits, the boilerplate is substantial. For every new context type — say MinimalApp or EmbeddedApp — you must write a fresh impl AppFields for … block that returns each field accessor one by one. If AppFields gains a new method (e.g. fn connection_timeout()) every existing implementation must be updated.

There is also a new expressivity problem that the trait approach cannot easily solve: what if different context types need different implementations of the same function? For example, what if one context uses SQLite instead of PostgreSQL, or a different object storage provider instead of S3? With generic functions and a shared trait, there is only one implementation body for get_user, and that body is hard-wired to the PostgreSQL-specific PgPool type. Swapping the database engine would require parameterizing the function differently, leading to a cascade of additional generic parameters and where-clauses all the way up the call stack.

The next chapter addresses all of these problems at once with #[cgp_fn].


Chapter 5 — Context-Generic Functions with #[cgp_fn]

Source file: src/cgp_fn.rs

Introducing #[cgp_fn] and #[implicit]

CGP v0.7.0 introduces the #[cgp_fn] macro, which lets you write functions that look almost identical to the plain function style from Chapter 2, but which are automatically context-generic and require no manual trait implementations on any context type.

Here is how get_user looks with #[cgp_fn]:

use aws_sdk_s3::Client;
use cgp::prelude::*;
use image::RgbImage;
use sqlx::PgPool;

use crate::contexts::app::App;
use crate::contexts::minimal::MinimalApp;
use crate::contexts::smart::SmartApp;
use crate::types::{User, UserId};

#[cgp_fn]
#[async_trait]
pub async fn get_user(
    &self,
    #[implicit] database: &PgPool,
    user_id: &UserId,
) -> anyhow::Result<User> {
    let user =
        sqlx::query_as("SELECT name, email, profile_picture_object_id FROM users WHERE id = $1")
            .bind(user_id.0 as i64)
            .fetch_one(database)
            .await?;

    Ok(user)
}

There are a few things to notice. First, &self appears in the argument list. This is the context: a reference to whichever concrete type this function is being called on. Second, database is annotated with #[implicit]. This tells #[cgp_fn] that database does not come from the caller — instead, it is automatically extracted from self using CGP's field access machinery. Third, user_id has no annotation, so it remains an explicit argument that callers must supply.

The #[async_trait] annotation is needed for async functions because of current limitations in stable Rust's async trait support.

The function body itself is identical to the plain function version from Chapter 2 — there is no new API to learn inside the body.

Defining fetch_storage_object with multiple implicit arguments

fetch_storage_object needs two implicit fields — storage_client and bucket_id — in addition to the explicitly supplied object_id:

#[cgp_fn]
#[async_trait]
pub async fn fetch_storage_object(
    &self,
    #[implicit] storage_client: &Client,
    #[implicit] bucket_id: &str,
    object_id: &str,
) -> anyhow::Result<Vec<u8>> {
    let output = storage_client
        .get_object()
        .bucket(bucket_id)
        .key(object_id)
        .send()
        .await?;

    let data = output.body.collect().await?.into_bytes().to_vec();
    Ok(data)
}

Multiple #[implicit] arguments are simply listed one after another. The caller never sees them; the CGP machinery wires them up automatically from whatever context self happens to be.

Composing CGP functions with #[uses]

When get_user_profile_picture needs to call get_user and fetch_storage_object as CGP methods on self, it must declare that dependency explicitly using the #[uses] attribute:

#[cgp_fn]
#[async_trait]
#[uses(GetUser, FetchStorageObject)]
pub async fn get_user_profile_picture(&self, user_id: &UserId) -> anyhow::Result<Option<RgbImage>> {
    let user = self.get_user(user_id).await?;

    if let Some(object_id) = user.profile_picture_object_id {
        let data = self.fetch_storage_object(&object_id).await?;
        let image = image::load_from_memory(&data)?.to_rgb8();

        Ok(Some(image))
    } else {
        Ok(None)
    }
}

#[uses(GetUser, FetchStorageObject)] names the traits that #[cgp_fn] generates for get_user and fetch_storage_object respectively. By convention, #[cgp_fn] converts a snake_case function name to a CamelCase trait name — so get_user becomes GetUser, and fetch_storage_object becomes FetchStorageObject. Without #[uses], the compiler would not know to require those capabilities on the Self context, and it would report missing method errors.

The body is identical to the version in Chapter 3: self.get_user(user_id) and self.fetch_storage_object(&object_id) are natural method calls. The #[uses] annotation takes care of threading the trait bounds behind the scenes.

Making a context work with #[cgp_fn]

The App context from Chapter 3 already has #[derive(HasField)] applied to it. That is all it takes. There are no impl AppFields for App blocks to write. CGP's field access machinery automatically derives implementations for any field whose name and type match the implicit argument. As long as App has a field named database of type PgPool, get_user will work on App without any further code.

This means that the three different contexts — App, MinimalApp, and SmartApp — gain access to get_user automatically just by deriving HasField:

// src/contexts/minimal.rs
#[derive(HasField)]
pub struct MinimalApp {
    pub database: PgPool,
}

// src/contexts/smart.rs
#[derive(HasField)]
pub struct SmartApp {
    pub database: PgPool,
    pub storage_client: Client,
    pub bucket_id: String,
    pub open_ai_client: openai::Client,
    pub open_ai_agent: Agent<openai::CompletionModel>,
}

MinimalApp only has a database field, so it can call get_user but not fetch_storage_object or get_user_profile_picture — and the compiler will simply refuse to compile any code that tries. SmartApp has all required fields, so it can call all three functions. At no point does the code for any of those functions need to mention MinimalApp or SmartApp by name.

The following type-level checks at the bottom of the file confirm that the expected compilations succeed:

pub trait CheckGetUserProfilePicture: GetUserProfilePicture {}

impl CheckGetUserProfilePicture for App {}

pub trait CheckGetUser: GetUser {}

impl CheckGetUser for App {}
impl CheckGetUser for MinimalApp {}
impl CheckGetUser for SmartApp {}

If any of these impl lines failed to compile, it would mean that the corresponding context is missing a required field or capability. Keeping these checks in the source file makes it easy to catch regressions early.

Summary of what #[cgp_fn] gives you

With #[cgp_fn] you get all the benefits of the trait approach from Chapter 4 — the functions work on any context type that satisfies the required fields — but without writing any impl Trait for ConcreteType boilerplate. You also avoid the context being passed as an explicit argument, so call sites look clean and idiomatic. And unlike the plain function style, adding a new context type never requires touching the function implementations themselves.


Chapter 6 — Generic Provider Implementations (Sneak Preview)

Source file: src/cgp_fn_generic.rs

This chapter is a sneak preview of a more advanced CGP capability: making the implementation itself generic over a provider parameter, not just over the context.

The motivation: supporting multiple database engines

In Chapter 5, the get_user function used &PgPool as an implicit argument, which hard-codes PostgreSQL as the database engine. But what if you want to support SQLite as well — for example, in an embedded or edge deployment? With plain traits or with #[cgp_fn] as shown in Chapter 5, you would need to write a separate get_user function for each database engine, or introduce awkward enum-dispatching logic.

CGP's #[impl_generics] attribute allows the implementation of a #[cgp_fn] function to introduce extra type parameters. Here is the extended version of get_user:

use sqlx::{Database, Pool};

use crate::contexts::embedded::EmbeddedApp;

#[cgp_fn]
#[async_trait]
#[impl_generics(Db: Database)]
pub async fn get_user(
    &self,
    #[implicit] database: &Pool<Db>,
    user_id: &UserId,
) -> anyhow::Result<User>
where
    i64: sqlx::Type<Db>,
    for<'a> User: sqlx::FromRow<'a, Db::Row>,
    for<'a> i64: sqlx::Encode<'a, Db>,
    for<'a> <Db as sqlx::Database>::Arguments<'a>: sqlx::IntoArguments<'a, Db>,
    for<'a> &'a mut <Db as sqlx::Database>::Connection: sqlx::Executor<'a, Database = Db>,
{
    let user =
        sqlx::query_as("SELECT name, email, profile_picture_object_id FROM users WHERE id = $1")
            .bind(user_id.0 as i64)
            .fetch_one(database)
            .await?;

    Ok(user)
}

The #[impl_generics(Db: Database)] attribute declares an extra type parameter Db that is not visible to callers. Inside the function body, database is now &Pool<Db> rather than &PgPool. The where clause enumerates the sqlx-specific constraints that Db must satisfy for the query to compile.

fetch_storage_object and get_user_profile_picture remain unchanged from Chapter 5, since they do not interact with the database.

How a context provides the Db parameter

The EmbeddedApp context carries a SqlitePool rather than a PgPool:

// src/contexts/embedded.rs
#[derive(HasField)]
pub struct EmbeddedApp {
    pub database: SqlitePool,
    pub storage_client: Client,
    pub bucket_id: String,
}

Because SqlitePool is Pool<Sqlite> under the hood, CGP can infer Db = Sqlite for EmbeddedApp and Db = Postgres for App, each satisfying the sqlx constraints independently through #[derive(HasField)] alone. The check at the bottom of the file confirms:

pub trait CheckGetUserProfilePicture: GetUserProfilePicture {}

impl CheckGetUserProfilePicture for App {}
impl CheckGetUserProfilePicture for EmbeddedApp {}

Both App (PostgreSQL) and EmbeddedApp (SQLite) pass the check, and neither needs a hand-written impl block for database access.

What #[impl_generics] unlocks

#[impl_generics] is a powerful escape hatch for situations where a single hardcoded type is too restrictive but you do not want to fully opt in to CGP's component system. It lets you write a single, generic implementation body and have the compiler resolve the specific types for each context independently. For beginners this is still an advanced topic, but it gives a taste of how far CGP can scale.


Chapter 7 — Alternative Implementations with #[cgp_impl] (Sneak Preview)

Source file: src/cgp_impl.rs

This chapter is a sneak preview of CGP's component system: the ability to define multiple named implementations of the same interface and wire up different implementations for different contexts.

The motivation: supporting multiple storage backends

In all previous chapters, fetch_storage_object assumed that object storage is provided by Amazon S3 via aws_sdk_s3::Client. But modern applications often need to support multiple storage backends — perhaps one deployment runs on AWS and another on Google Cloud. With plain Rust, the only ways to handle this are an enum of backends, dynamic dispatch (Box<dyn Trait>), or compile-time feature flags. All of these impose trade-offs on ergonomics or performance.

CGP's component system offers a fourth option: provide two entirely separate implementations of the same interface and let each context select the one it wants at the type level — with zero runtime overhead.

Defining a CGP component

First, we convert CanFetchStorageObject into a full CGP component using #[cgp_component]:

#[async_trait]
#[cgp_component(StorageObjectFetcher)]
pub trait CanFetchStorageObject {
    async fn fetch_storage_object(&self, object_id: &str) -> anyhow::Result<Vec<u8>>;
}

The #[cgp_component(StorageObjectFetcher)] attribute makes this a proper CGP component. StorageObjectFetcher is the name of the provider trait, and CGP also generates a StorageObjectFetcherComponent type that serves as the component's unique identity.

Writing multiple implementations with #[cgp_impl]

Next, we write two independent implementations using #[cgp_impl]. Each implementation targets the StorageObjectFetcher provider trait and introduces a distinct provider name:

#[cgp_impl(new FetchS3Object)]
impl StorageObjectFetcher {
    async fn fetch_storage_object(
        &self,
        #[implicit] storage_client: &Client,
        #[implicit] bucket_id: &str,
        object_id: &str,
    ) -> anyhow::Result<Vec<u8>> {
        let output = storage_client
            .get_object()
            .bucket(bucket_id)
            .key(object_id)
            .send()
            .await?;

        let data = output.body.collect().await?.into_bytes().to_vec();
        Ok(data)
    }
}

#[cgp_impl(new FetchGCloudObject)]
impl StorageObjectFetcher {
    async fn fetch_storage_object(
        &self,
        #[implicit] storage_client: &Storage,
        #[implicit] bucket_id: &str,
        object_id: &str,
    ) -> anyhow::Result<Vec<u8>> {
        let mut reader = storage_client
            .read_object(bucket_id, object_id)
            .send()
            .await?;

        let mut contents = Vec::new();
        while let Some(chunk) = reader.next().await.transpose()? {
            contents.extend_from_slice(&chunk);
        }

        Ok(contents)
    }
}

#[cgp_impl(new FetchS3Object)] declares a new provider named FetchS3Object that uses aws_sdk_s3::Client. #[cgp_impl(new FetchGCloudObject)] declares a second provider named FetchGCloudObject that uses google_cloud_storage::client::Storage. Both look like ordinary method implementations; the new keyword in the attribute tells CGP to also define the provider struct automatically.

Crucially, notice that #[implicit] is still used here for storage_client and bucket_id — it works seamlessly inside #[cgp_impl] just as it does inside #[cgp_fn].

Wiring implementations to contexts

The connection between a context and a specific provider is made with the delegate_components! macro:

delegate_components! {
    App {
        StorageObjectFetcherComponent: FetchS3Object,
    }
}

delegate_components! {
    GCloudApp {
        StorageObjectFetcherComponent: FetchGCloudObject,
    }
}

App — which carries an aws_sdk_s3::Client field — is wired to FetchS3Object. GCloudApp — which carries a google_cloud_storage::client::Storage field — is wired to FetchGCloudObject. Both context definitions are still purely data structs with #[derive(HasField)]; all the implementation selection happens in one place, in the delegate_components! block.

The get_user function continues to be defined with #[cgp_fn] and #[implicit], and get_user_profile_picture now uses #[uses(GetUser, CanFetchStorageObject)] to express its dependencies:

#[cgp_fn]
#[async_trait]
#[uses(GetUser, CanFetchStorageObject)]
pub async fn get_user_profile_picture(&self, user_id: &UserId) -> anyhow::Result<Option<RgbImage>> {
    let user = self.get_user(user_id).await?;

    if let Some(object_id) = user.profile_picture_object_id {
        let data = self.fetch_storage_object(&object_id).await?;
        let image = image::load_from_memory(&data)?.to_rgb8();

        Ok(Some(image))
    } else {
        Ok(None)
    }
}

The type-level wiring checks confirm that both contexts compile successfully end-to-end:

pub trait CheckGetUserProfilePicture: GetUserProfilePicture {}

impl CheckGetUserProfilePicture for App {}
impl CheckGetUserProfilePicture for GCloudApp {}

What #[cgp_impl] and delegate_components! give you

The combination of #[cgp_impl] and delegate_components! gives you zero-cost, type-safe dependency injection. The storage backend is selected entirely at compile time: the compiled binary for App will contain only the S3 code paths, and the binary for GCloudApp will contain only the GCloud code paths. There is no runtime overhead, no allocation, and no match or enum dispatch.

Adding a third backend — say, Azure Blob Storage — requires nothing more than a new #[cgp_impl(new FetchAzureObject)] block and a new delegate_components! entry. All existing code — including get_user, get_user_profile_picture, and every other context — remains untouched.


Appendix — Desugared Form of #[cgp_fn]

Source file: src/cgp_fn_desugared.rs

This appendix shows the code that the #[cgp_fn] macro generates under the hood, written by hand. You do not need to write code like this yourself, but reading it can help you understand how CGP works in terms of ordinary Rust constructs, and it may help when reading compiler error messages.

The consumer traits

#[cgp_fn] turns each function into an ordinary async trait. Here is what the generated code looks like for all three functions:

use aws_sdk_s3::Client;
use cgp::prelude::*;
use image::RgbImage;
use sqlx::PgPool;

use crate::contexts::app::App;
use crate::contexts::minimal::MinimalApp;
use crate::contexts::smart::SmartApp;
use crate::types::{User, UserId};

#[async_trait]
pub trait GetUser {
    async fn get_user(&self, user_id: &UserId) -> anyhow::Result<User>;
}

#[async_trait]
pub trait FetchStorageObject {
    async fn fetch_storage_object(&self, object_id: &str) -> anyhow::Result<Vec<u8>>;
}

#[async_trait]
pub trait GetUserProfilePicture {
    async fn get_user_profile_picture(&self, user_id: &UserId) -> anyhow::Result<Option<RgbImage>>;
}

Each trait is a conventional Rust async trait. The implicit arguments — database, storage_client, and bucket_id — do not appear in the trait method signatures at all. They are part of the implementation, not the interface.

The blanket implementations

What makes the traits automatically available on any conforming context is a set of blanket impl blocks. The where clause of each blanket impl encodes the dependencies that were expressed as #[implicit] arguments and #[uses] entries:

impl<Context> GetUser for Context
where
    Self: HasField<Symbol!("database"), Value = PgPool>,
{
    async fn get_user(&self, user_id: &UserId) -> anyhow::Result<User> {
        let database: &PgPool = self.get_field(PhantomData::<Symbol!("database")>);
        let user = sqlx::query_as(
            "SELECT name, email, profile_picture_object_id FROM users WHERE id = $1",
        )
        .bind(user_id.0 as i64)
        .fetch_one(database)
        .await?;
        Ok(user)
    }
}

impl<Context> FetchStorageObject for Context
where
    Self: HasField<Symbol!("storage_client"), Value = Client>
        + HasField<Symbol!("bucket_id"), Value = String>,
{
    async fn fetch_storage_object(&self, object_id: &str) -> anyhow::Result<Vec<u8>> {
        let storage_client: &Client = self.get_field(PhantomData::<Symbol!("storage_client")>);
        let bucket_id: &str = self.get_field(PhantomData::<Symbol!("bucket_id")>).as_str();
        let output = storage_client
            .get_object()
            .bucket(bucket_id)
            .key(object_id)
            .send()
            .await?;
        let data = output.body.collect().await?.into_bytes().to_vec();
        Ok(data)
    }
}

impl<Context> GetUserProfilePicture for Context
where
    Self: GetUser + FetchStorageObject,
{
    async fn get_user_profile_picture(&self, user_id: &UserId) -> anyhow::Result<Option<RgbImage>> {
        let user = self.get_user(user_id).await?;
        if let Some(object_id) = user.profile_picture_object_id {
            let data = self.fetch_storage_object(&object_id).await?;
            let image = image::load_from_memory(&data)?.to_rgb8();
            Ok(Some(image))
        } else {
            Ok(None)
        }
    }
}

The HasField<Symbol!("database"), Value = PgPool> constraint is how CGP's field access machinery is spelled at the type level. It says: "this context must have a field called database of type PgPool". The self.get_field(PhantomData::<Symbol!("database")>) call is the runtime mechanism that reads that field by name. All of this boilerplate is generated for you by #[cgp_fn].

For GetUserProfilePicture, the where Self: GetUser + FetchStorageObject constraint combines the two delegated capabilities, matching the #[uses(GetUser, FetchStorageObject)] attribute from Chapter 5.

Why you do not need to write this by hand

Comparing the desugared form with the #[cgp_fn] form from Chapter 5 makes it immediately clear why the macro exists. The desugared version requires you to:

  • Define each trait by hand.
  • Write a blanket impl<Context> block for each trait.
  • Manually call self.get_field(PhantomData::<Symbol!("…")>) for every implicit argument.
  • Keep the where clause of each blanket impl in sync with the function's requirements.

The #[cgp_fn] macro takes care of all of that from a single annotated function definition. The result is code that compiles to exactly the same machine instructions, but that requires a fraction of the effort to write and maintain.


Learning More

This tutorial covered the first steps in a much larger landscape. CGP has a rich component system, error handling utilities, and patterns for large-scale modular design. To continue learning, visit the CGP documentation website at contextgeneric.dev.

License

This project is released under the MIT License.

About

Demo for `#[cgp_fn]` at Rust Berlin 2026-03

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages