Domain Model with Optics

E-commerce domain model using Lens composition, Prism, and profunctor transform.

Overview

Real-world domain models contain both product types (structs with named fields) and sum types (enums with distinct variants). Karpal provides two complementary optics for working with them:

Both optics support transform, which uses the Profunctor abstraction (FnP) to produce a reusable S -> S update function from an A -> A inner function. This is the key to composable, first-class data transformations.

The Domain Model

The example defines an e-commerce order with nested structs and an enum for payment methods:

#[derive(Debug, Clone, PartialEq)]
struct Order {
    id: u32,
    customer: Customer,
    items: Vec<Item>,
    payment: Payment,
}

#[derive(Debug, Clone, PartialEq)]
struct Customer {
    name: String,
    address: Address,
}

#[derive(Debug, Clone, PartialEq)]
struct Address {
    street: String,
    city: String,
    zip: String,
}

#[derive(Debug, Clone, PartialEq)]
struct Item {
    name: String,
    price_cents: i64,
    quantity: u32,
}

#[derive(Debug, Clone, PartialEq)]
enum Payment {
    CreditCard { last4: String, exp: String },
    BankTransfer { iban: String },
    Wallet { provider: String, balance_cents: i64 },
}

Order, Customer, Address, and Item are product types — every instance has every field. Payment is a sum type — each order uses exactly one payment method.

Defining Lenses

A SimpleLens<S, A> is created with Lens::new, which takes a getter (&S -> A) and a setter ((S, A) -> S):

fn customer_lens() -> SimpleLens<Order, Customer> {
    Lens::new(
        |o: &Order| o.customer.clone(),
        |o, customer| Order { customer, ..o },
    )
}

fn address_lens() -> SimpleLens<Customer, Address> {
    Lens::new(
        |c: &Customer| c.address.clone(),
        |c, address| Customer { address, ..c },
    )
}

fn city_lens() -> SimpleLens<Address, String> {
    Lens::new(
        |a: &Address| a.city.clone(),
        |a, city| Address { city, ..a },
    )
}

Each lens is a small, self-contained unit that knows how to read and write a single field. The setter uses Rust's struct update syntax (..o) to copy all other fields unchanged.

Lens Composition with .then()

Individual lenses compose into a ComposedLens via .then(). This lets you reach deeply nested fields without manually threading getters and setters:

let order_city = customer_lens().then(address_lens()).then(city_lens());
let order_zip  = customer_lens().then(address_lens()).then(zip_lens());

// Deep get
println!("Order city: {}", order_city.get(&order));
println!("Order zip:  {}", order_zip.get(&order));

// Deep set (returns a new Order, original unchanged)
let updated = order_city.set(order.clone(), "Shelbyville".into());

// Deep over (apply a function to the focused value)
let uppercased = order_city.over(order.clone(), |c| c.to_uppercase());

The composed lens order_city has type ComposedLens<Order, String>. It supports the same get, set, and over operations as a simple lens, but it reaches three levels deep: Order -> Customer -> Address -> city.

Lens Transform with FnP

The transform method converts a lens and an inner function into a reusable update function. It uses the FnP profunctor (a boxed function type) to lift an A -> A function into an S -> S function:

let normalize_city: Box<dyn Fn(String) -> String> =
    Box::new(|c| c.trim().to_uppercase());
let normalize_order_city = city_lens().transform::<FnP>(normalize_city);

let addr = Address {
    street: "456 Oak Ave".into(),
    city: "  new york  ".into(),
    zip: "10001".into(),
};
let normalized = normalize_order_city(addr);
// normalized.city == "NEW YORK"

The result is a plain Address -> Address function that normalizes only the city field. You can store it, pass it around, and apply it to any Address value.

Defining Prisms

A SimplePrism<S, A> is created with Prism::new, which takes a match function (S -> Result<A, S>) and a build function (A -> S). The match returns Ok(a) if the variant matches, or Err(s) with the original value if it does not:

fn credit_card_prism() -> SimplePrism<Payment, (String, String)> {
    Prism::new(
        |p| match p {
            Payment::CreditCard { last4, exp } => Ok((last4, exp)),
            other => Err(other),
        },
        |(last4, exp)| Payment::CreditCard { last4, exp },
    )
}

fn wallet_prism() -> SimplePrism<Payment, (String, i64)> {
    Prism::new(
        |p| match p {
            Payment::Wallet { provider, balance_cents } => Ok((provider, balance_cents)),
            other => Err(other),
        },
        |(provider, balance_cents)| Payment::Wallet { provider, balance_cents },
    )
}

fn bank_transfer_prism() -> SimplePrism<Payment, String> {
    Prism::new(
        |p| match p {
            Payment::BankTransfer { iban } => Ok(iban),
            other => Err(other),
        },
        |iban| Payment::BankTransfer { iban },
    )
}

Prism Operations

Prisms provide three core operations:

let cc = credit_card_prism();
let wallet = wallet_prism();

// preview: extract if matched
cc.preview(&order.payment);      // Some(("4242", "12/25"))
wallet.preview(&order.payment);  // None (order pays by credit card)

// review: construct a variant
let new_payment = wallet.review(("PayPal".into(), 5000));
// Payment::Wallet { provider: "PayPal", balance_cents: 5000 }

// over: modify only if matched
let updated_payment = cc.over(order.payment.clone(), |(last4, _exp)| {
    (last4, "01/28".into())
});
// Updates the expiry; leaves other fields intact

// over on non-matching variant: passes through unchanged
let unchanged = wallet.over(order.payment.clone(), |(prov, bal)| {
    (prov, bal + 1000)
});
// Still CreditCard — wallet.over is a no-op here

Prism Transform

Like lenses, prisms support transform to produce a reusable S -> S function. The transformed function applies the inner modification when the variant matches and returns the value unchanged otherwise:

let add_balance: Box<dyn Fn((String, i64)) -> (String, i64)> =
    Box::new(|(prov, bal)| (prov, bal + 2500));
let add_wallet_balance = wallet_prism().transform::<FnP>(add_balance);

let wallet_payment = Payment::Wallet {
    provider: "PayPal".into(),
    balance_cents: 10000,
};
let topped_up = add_wallet_balance(wallet_payment);
// Payment::Wallet { provider: "PayPal", balance_cents: 12500 }

// Apply to a non-wallet payment — passes through unchanged
let still_cc = add_wallet_balance(order.payment.clone());
// Still CreditCard { last4: "4242", exp: "12/25" }

Combining Lenses and Prisms

In practice you use lenses and prisms together. Lenses drill into product type fields; prisms branch on sum type variants. The example demonstrates iterating over a collection of orders and using both optics:

let order_city_lens = customer_lens().then(address_lens()).then(city_lens());

for o in &orders {
    let city = order_city_lens.get(o);
    let name = name_lens().get(&o.customer);
    let payment_type = match &o.payment {
        Payment::CreditCard { .. } => "CC",
        Payment::BankTransfer { .. } => "Bank",
        Payment::Wallet { .. } => "Wallet",
    };
    println!("Order #{}: {} ({}, pays via {})", o.id, city, name, payment_type);
}

// Extract all bank IBANs using a prism
let bank = bank_transfer_prism();
for o in &orders {
    if let Some(iban) = bank.preview(&o.payment) {
        println!("Order #{}: {}", o.id, iban);
    }
}

Run It

To run this example from the workspace root:

cargo run -p karpal-std --example domain_model_optics

Traits Used

Trait / TypeRole in this exampleReference
Lens / SimpleLens Focus on a single field in a product type; get, set, over Optics
ComposedLens Chain lenses with .then() for deep nested access Optics
Prism / SimplePrism Focus on a single variant of a sum type; preview, review, over Optics
FnP Profunctor marker type for transform; lifts A -> A to S -> S Profunctor Family
Strong Profunctor subclass used internally by Lens transform Profunctor Family
Choice Profunctor subclass used internally by Prism transform Profunctor Family