quickstart.md gets a model generating a client and server; this guide covers everything after that — the loop you run every time the model changes. The shape of the loop depends on where generated code lives, and there are exactly two answers:
| Mode | Generated code lives | Drift possible? | The loop |
|---|---|---|---|
| In-graph (the Bazel rules, recommended) | inside the build graph, never on disk | No — every build regenerates | edit model → bazel test //... → fix what the compiler names |
Vendored (CLI output checked into git; also this repo's own examples/*/generated and protocol-tests/) |
in the repository | Yes — model and output can diverge | edit model → regenerate → review the diff → commit both together |
With smithy_cpp_{types,client,server}_library (quickstart.md §3), the model
is the only source of truth: generation is a hermetic action, so an edited .smithy file
invalidates exactly the targets that depend on it. There is nothing to regenerate by hand and
nothing to drift. The compiler is the review:
- Adding an optional member is source-compatible. Existing handlers, call sites, and tests
rebuild unchanged; the new member shows up as a
std::optional<T>(or@default-initialized) field on the generated struct. - Adding an operation grows the generated
<Service>Handlerinterface by one pure-virtual method, so every handler implementation stops compiling until it implements the new operation. The unimplemented surface is found by the compiler, not by a 404 in production. - Renaming or removing anything shows up the same way: every call site that mentioned the old name fails to compile. Rename-refactors are grep-follow-the-errors sessions, not archaeology.
Both properties are pinned in CI:
examples/bazel-consumer/model-evolution-check.sh
scripts the loop against the out-of-tree consumer module — stage 1 adds an optional member and
requires bazel test //... to stay green untouched; stage 2 adds a ListTasks operation and
requires the integration test to fail compilation naming the new handler method. The
consumer CI job runs it on every commit, so the promises in this section cannot silently rot.
Vendoring generated sources (the CLI escape hatch in quickstart.md
"Generating outside Bazel") trades the in-graph guarantees for a plain source tree, and takes
on one obligation: the model and its generated output must change in the same commit. This
repo's own fixtures live this way — examples/*/generated/ and protocol-tests/*/generated/
are checked in as goldens — so the workflow below is exercised on every commit here.
-
Edit the model (
examples/<name>/model/*.smithy, or your own). -
Regenerate. In this repo, one command per fixture or all at once:
cd codegen gradle generateWeatherFixture # one fixture… gradle generateFixtures generateProtocolTests # …or everything checked in
Out of tree, the same generator runs as a CLI:
bazel run @smithy_cpp//codegen:generator -- \ --model $PWD/model/todo.smithy --service acme.todo#Todo \ --namespace acme::todo --mode both --output $PWD/generatedThe Gradle tasks delete the output directory before regenerating, so removals show up as deleted lines in the diff rather than orphaned files; do the same for vendored output (
rm -rf generated/ && bazel run …). -
Review the diff as a normal diff. Generation is byte-deterministic (enforced by
CppCodegenPluginTest.outputIsByteDeterministic), so the diff is exactly the blast radius of the model change — no noise, no timestamps. What to look for:include/**/types.h— the API surface consumers compile against. New members and operations should look additive; anything removed here is a breaking change to your consumers (versioning.md §"What counts as the compatibility surface").src/serde.cc,src/client.cc,src/server.cc— the wire behavior. Internals may reshuffle freely between generator versions (explicitly not a compatibility surface), but for a pure model edit with a pinned generator the changes should mention only the shapes you touched.tests/— regenerated integration/smoke suites pick up new members automatically.
-
Build and test, then commit the model and generated output together.
A vendored tree needs CI to fail fast when someone edits a model without regenerating (or
edits generated files by hand). The pattern is: regenerate, then require a clean tree. This
repo's codegen job does precisely that for the checked-in goldens:
- name: check generated fixtures are current
run: |
(cd codegen && gradle generateFixtures generateProtocolTests)
git diff --exit-code -- examples protocol-testsA consumer vendoring CLI output wants the same job, substituting the CLI invocation:
- name: check generated code is current
run: |
rm -rf generated
bazel run @smithy_cpp//codegen:generator -- \
--model $PWD/model/todo.smithy --service acme.todo#Todo \
--namespace acme::todo --mode both --output $PWD/generated
git diff --exit-code -- generatedTwo conventions that keep the check honest:
- Pin the generator. Byte-identical regeneration is only guaranteed for a pinned generator
version (
git_overridecommit / release tag); across generator upgrades the conformance suites are the contract instead (versioning.md §"What a generator upgrade may change"). Upgrading the pin regenerates everything in one reviewable commit. - Exempt generated code from format checks. Its shape is locked by the drift check; this
repo's lint job skips
*/generated/*for clang-format/clang-tidy rather than fighting the generator.
The smallest real evolution, run against this repo. Add chanceOfSnow to GetForecast's
output in examples/weather/model/weather.smithy:
output := {
chanceOfRain: Float
chanceOfSnow: Float
}Regenerate that one fixture and look at the blast radius:
(cd codegen && gradle generateWeatherFixture)
git diff --stat -- examples/weather
# .../generated/include/example/weather/types.h | 1 +
# .../generated/src/serde.cc | 15 +++++++++++++++
# .../generated/src/server.cc | 3 +++
# .../generated/tests/integration_test.cc | 1 +
# .../model/weather.smithy | 2 ++22 added lines, nothing removed. The interesting hunk is the API surface:
struct GetForecastOutput {
std::optional<float> chanceOfRain{};
+ std::optional<float> chanceOfSnow{};
friend bool operator==(const GetForecastOutput&, const GetForecastOutput&) = default;
};plus the matching serialize/deserialize clauses in serde.cc/server.cc and one line in the
regenerated random-round-trip integration test. bazel test //examples/weather/... passes
with every hand-written test untouched — additive members are invisible to code that does not
use them. Commit weather.smithy and examples/weather/generated/ together and the drift
check stays green.
Wire compatibility (old peers keep working) and source compatibility (existing C++ keeps compiling) are separate questions:
| Model change | Wire | Source (consumers of generated headers) |
|---|---|---|
| Add optional member | ✅ old readers ignore it; absent → unset std::optional |
✅ additive |
Add member with @default |
✅ absent → the default | ✅ additive (plain member, default-initialized) |
| Add operation | ✅ old clients never call it | |
| Add enum value | ✅ old clients preserve unknown values (string enums: Value::kUnknown + original text; intEnums keep the in-range value) — ValidationException |
✅ additive |
Promote optional → @required + @default |
✅ absence on the wire keeps the default (the generator's evolution leniency) | ✅ member becomes plain (non-optional) — call sites reading .has_value() need updating |
Promote optional → @required (no default) |
❌ old writers that omit it now fail deserialization | ❌ member type changes |
| Rename member | ❌ wire key changes — unless the old wire name is kept via @jsonName |
❌ compile errors at every use |
| Remove member / operation | ❌ breaking | ❌ breaking |
Tighten a constraint (@length, @pattern, @range) |
ValidationException before the handler runs |
✅ no header change |
For anything in the ❌ rows, versioning.md applies: it is a breaking change to your service's consumers, and (for changes to this repo's generator itself) must be called out in the CHANGELOG under a "Breaking" heading with a migration note.
Adding a member to a @streaming event union is a wire-breaking change
for live peers: an unknown :event-type is a terminal serialization
error on the receiving side (the closed-union posture, applied to
streams), so old peers' sessions end at the first new-member event.
Roll out receivers before senders, or model a new operation.