Skip to main contentLibrary API Design, First Layer | IoT Worker

Library API Design, First Layer

In C library APIs, one pointer can mean many different things:

int parse_packet(const uint8_t *data, size_t len, struct Packet *out);
void normalize(uint8_t *data, size_t len);
void consume_buffer(uint8_t *data, size_t len);
uint8_t *make_packet(size_t *out_len);

These signatures all involve pointers, but the API meaning differs:

  • read-only input
  • in-place mutation
  • output result
  • taking over a caller-owned resource
  • creating a new resource for the caller

The first layer of Rust API design is making those intentions visible in the type.

Read-Only Input: Borrow

C read-only buffer:

uint32_t checksum(const uint8_t *data, size_t len);

Rust uses a slice:

fn checksum(data: &[u8]) -> u32 {
    data.iter().map(|b| *b as u32).sum()
}

&[u8] means the function temporarily reads bytes. It does not own the memory and cannot mutate it.

Text is similar. Do not write:

fn print_name(name: String) {
    println!("{name}");
}

If the function only reads:

fn print_name(name: &str) {
    println!("{name}");
}

Callers can pass a string literal or a borrowed String:

print_name("sensor");

let name = String::from("device");
print_name(&name);

First rule:

function only reads: prefer &T / &[T] / &str

In-Place Mutation: Mutable Borrow

C mutates a caller buffer:

void xor_in_place(uint8_t *data, size_t len, uint8_t key);

Rust:

fn xor_in_place(data: &mut [u8], key: u8) {
    for byte in data {
        *byte ^= key;
    }
}

The caller must explicitly pass a mutable borrow:

let mut data = vec![1_u8, 2, 3];
xor_in_place(&mut data, 0xAA);

For a struct:

fn reset_config(config: &mut Config) {
    config.retry = 0;
}

Second rule:

function mutates caller data in place: take &mut T / &mut [T]

Creating New Data: Return an Owning Type

C often returns allocated memory plus an output length:

uint8_t *encode_packet(const uint8_t *payload, size_t len, size_t *out_len);

Ordinary Rust APIs should return the owned value:

fn encode_packet(payload: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(payload.len() + 2);
    out.push(0xAA);
    out.extend_from_slice(payload);
    out.push(0x55);
    out
}

If it can fail:

fn encode_packet(payload: &[u8]) -> Result<Vec<u8>, EncodeError> {
    if payload.len() > u8::MAX as usize {
        return Err(EncodeError::TooLarge);
    }

    let mut out = Vec::with_capacity(payload.len() + 2);
    out.push(0xAA);
    out.extend_from_slice(payload);
    out.push(0x55);
    Ok(out)
}

Text is similar:

fn make_topic(device: &str) -> String {
    format!("devices/{device}/status")
}

Third rule:

function creates new data: return Vec<T> / String / Box<T> / a custom owned type

Output Parameters: Return When You Can

C output parameters are common:

int parse_header(const uint8_t *data, size_t len, struct Header *out);

Rust can return the parsed value:

fn parse_header(data: &[u8]) -> Result<Header, ParseError> {
    if data.len() < 4 {
        return Err(ParseError::TooShort);
    }

    Ok(Header {
        version: data[0],
        flags: data[1],
        len: u16::from_be_bytes([data[2], data[3]]),
    })
}

For multiple results, return a tuple or struct:

fn parse_field(data: &[u8]) -> Result<(Field, usize), ParseError> {
    let field = parse_one_field(data)?;
    Ok((field, field.encoded_len()))
}

Use a mutable output buffer only when you need to reuse caller memory, avoid allocation, or stay close to a low-level interface:

fn encode_into(payload: &[u8], out: &mut [u8]) -> Result<usize, EncodeError> {
    if out.len() < payload.len() + 2 {
        return Err(EncodeError::TooSmall);
    }

    out[0] = 0xAA;
    out[1..1 + payload.len()].copy_from_slice(payload);
    out[1 + payload.len()] = 0x55;
    Ok(payload.len() + 2)
}

Fourth rule:

C output parameter: Rust should usually return a value; use &mut only when caller storage matters

Taking Over a Resource: Take by Value

Some C APIs take ownership of caller memory:

void queue_take_packet(uint8_t *packet, size_t len);

After the call, the caller must not free or use the buffer. In Rust, this API should take an owned value:

fn queue_take_packet(packet: Vec<u8>) {
    println!("queued {}", packet.len());
}

Caller:

let packet = vec![1_u8, 2, 3];
queue_take_packet(packet);
// packet cannot be used again

If the function only inspects the packet, do not take Vec<u8>:

fn inspect_packet(packet: &[u8]) {
    println!("len = {}", packet.len());
}

Fifth rule:

function takes over a resource: take Vec<T> / String / File / a custom owned type by value
function only uses a resource temporarily: take a borrow

Struct Fields Are API Too

API shape also appears in struct fields.

If a struct owns configuration text:

struct Config {
    name: String,
}

If it is only a view into external data, it can store a reference, but that exposes a lifetime parameter:

struct View<'a> {
    data: &'a [u8],
}

For first-layer library APIs, if you do not want to expose lifetimes to callers, owning the data is often simpler:

struct Packet {
    data: Vec<u8>,
}

This does not mean reference fields are wrong. It means they make “this struct cannot outlive the borrowed data” part of the API. Do not make every field a reference by default.

A Decision Table

When designing a Rust library API, ask these questions first:

1. Does the function only read?
   -> &T / &[T] / &str

2. Does it mutate in place?
   -> &mut T / &mut [T]

3. Does it create new data for the caller?
   -> return Vec<T> / String / Box<T> / custom type

4. Does it take over a caller resource?
   -> take Vec<T> / String / File / custom owned type by value

5. Does it reuse caller buffer to avoid allocation?
   -> input &[T], output &mut [T], return written length or Result<usize, E>

This is more useful than asking whether Rust “wants ownership.” API design is about resource relationships, not syntax.

What to Keep

The first C-to-Rust API mapping:

const T *                 -> &T
const T * + len           -> &[T]
T * + len in-place write  -> &mut [T]
return malloc pointer     -> return Vec<T> / String / Box<T>
out parameter             -> usually return value / Result<T, E>
takes over caller resource -> take an owned type by value
temporary use of resource -> take a borrow

A good Rust API does not make everything Vec, String, or raw pointer. The signature should say whether the function reads, mutates, creates, or takes over.

Continue Reading