Mehdi Akiki
Published on

Should generated API clients live in their own Rust crate?

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Derived state

The service contains a large generated client and a small amount of frequently edited application code. Changing a handler seems to make too much compilation happen again.

Moving the client into its own crate is a reasonable experiment. It is not an automatic build-time improvement.

I would make the boundary answer a concrete question: can ordinary service edits reuse the expensive generated part without paying more elsewhere than the split saves?

Keep the proposed boundary simple

One possible layout is:

workspace/
  provider-client/   generated types and transport bindings
  service/           product logic and handlers

The client crate should not depend on the service that consumes it. Put shared input contracts at a deliberate lower layer rather than creating a circular dependency through a convenience module.

Keep generation ownership clear. Either generated source is checked in with a reviewable update procedure, or a declared build step produces it. The split does not fix a generator with hidden inputs.

The crate-boundary investigation explains what becomes reusable and what can still be instantiated downstream. Generic functions and types can carry code-generation work into the consuming crate.

Measure changes people actually make

Compare the current and proposed layouts with the same generated source, dependency graph, toolchain, profile, and enabled features.

Use at least three edits: a handler-only change, a generated-schema change, and a change to a shared type used by both sides. Include a clean build so the incremental gain is not presented as the whole cost.

Cargo's timing report can show the shape of work through the existing CI workflow. The slow-build diagnostic guide helps decide whether this boundary addresses the phase that is actually expensive.

Record which crates rebuild and where the time goes. A faster handler edit matters when it matches the team's normal cycle. A one-off warm build that happens to hit a cache is weaker evidence.

Look at the API crossing the boundary

A narrow client interface can help review and testing as well as builds. It can also add wrappers, feature coordination, and release work.

Do not hide provider-specific error or pagination semantics behind a generic method just to make the crate look clean. The application still needs enough information to recover correctly. Keep the product's stable model separate from generated provider types where that distinction helps.

Watch feature unification and native dependencies. A crate split can change graph structure without removing the heavy dependency from the build you care about.

Keep the smaller layout when it wins

For a small client, the extra crate may add little reuse and more maintenance. For generated code that changes as often as the handlers, the expected incremental advantage may be small.

There is no need to defend the split after the measurements reject it. Preserve the comparison and choose the simpler layout when its costs are better.

Keep the split only if the measured edit cycle and maintenance cost justify it. Record which source changes can reuse the generated client and which still rebuild it. That gives the next engineer something concrete to compare when the workspace changes again.

A measured split that helps one edit and hurts another

The crate trial creates two dependency-free layouts. Both contain the same generated lookup function with 512 match arms. One keeps it as a module in the application. The other puts it in a path dependency called generated_client.

Each layout is built before the timed edits. Incremental compilation is disabled, and the release profile uses optimization level one without debug information. The driver then makes three application-only edits and three generated-source edits. It checks the executed binary's numeric answer after every build, so a stale executable cannot count as a fast result.

In the recorded run, application edits took roughly 75 to 79 milliseconds in the inline layout and 68 to 69 milliseconds after the split. Generated-source edits took about 75 milliseconds inline and 104 milliseconds after the split. For this tiny example, an extra compilation unit had a visible cost when generated code changed.

These are short build times on one shared CI environment. Startup, filesystem checks, and linking are a large part of such small measurements. The trial does not justify promising a percentage improvement for a large generated client. It also does not measure a clean-build advantage, generic API changes, or a native dependency graph. Those remain separate trials before adopting the split in a real workspace.

The driver retains verbose Cargo output and the raw samples. Its reuse classifier is kept separate from elapsed time: a build can take a different amount of time without changing which dependency was reusable. Inspect Cargo's explanation before assigning the movement to a crate boundary.

This result gives the decision a shape. A service with many handler edits and rare schema changes may value reuse. A small application with frequent generated changes may prefer fewer crates. The choice should follow the actual edit cycle and API boundary, rather than turn a modest reproduction into a rule for every Rust workspace.