Why Smithy Over OpenAPI
OpenAPI describes HTTP APIs. That's both its strength and its limitation. When you write an OpenAPI spec, you're describing paths, methods, request bodies, and response schemas. The HTTP transport is baked into the model.
Smithy describes APIs. Period. The transport is a separate concern applied via protocol traits. This means the same Smithy model can generate:
- A REST/JSON SDK
- A gRPC client
- An event-driven client (for WebSocket or MQTT APIs)
- API documentation
- Server stubs
Here's what a Smithy model looks like for a simple service:
$version: "2"
namespace com.example.inventory
use aws.protocols#restJson1
@restJson1
@title("Inventory Service")
@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "maxResults")
service InventoryService {
version: "2026-01-15"
operations: [
ListProducts
GetProduct
CreateProduct
UpdateProduct
DeleteProduct
]
resources: [Product]
}
resource Product {
identifiers: { productId: ProductId }
read: GetProduct
create: CreateProduct
update: UpdateProduct
delete: DeleteProduct
list: ListProducts
}
string ProductId
@readonly
@http(method: "GET", uri: "/products/{productId}")
operation GetProduct {
input := {
@required
@httpLabel
productId: ProductId
}
output := {
@required
product: ProductShape
}
errors: [ProductNotFound, ValidationError]
}
@http(method: "POST", uri: "/products")
operation CreateProduct {
input := {
@required
name: String
@required
sku: String
price: BigDecimal
@length(min: 0, max: 10)
tags: TagList
metadata: MetadataMap
}
output := {
@required
product: ProductShape
}
errors: [ConflictError, ValidationError]
}
@paginated
@readonly
@http(method: "GET", uri: "/products")
operation ListProducts {
input := {
@httpQuery("maxResults")
@range(min: 1, max: 100)
maxResults: Integer
@httpQuery("nextToken")
nextToken: String
@httpQuery("category")
category: String
}
output := {
@required
products: ProductList
nextToken: String
}
}
structure ProductShape {
@required
productId: ProductId
@required
name: String
@required
sku: String
price: BigDecimal
tags: TagList
metadata: MetadataMap
@required
createdAt: Timestamp
updatedAt: Timestamp
}
list ProductList {
member: ProductShape
}
list TagList {
member: String
}
map MetadataMap {
key: String
value: String
}
@error("client")
@httpError(404)
structure ProductNotFound {
@required
message: String
productId: ProductId
}
@error("client")
@httpError(409)
structure ConflictError {
@required
message: String
}
@error("client")
@httpError(400)
structure ValidationError {
@required
message: String
fieldErrors: FieldErrorList
}
list FieldErrorList {
member: FieldError
}
structure FieldError {
@required
field: String
@required
message: String
}
Notice several things:
-
Resources are first-class.
Productis declared as a resource with CRUD operations mapped to it. This isn't just documentation, the code generator uses resource relationships to generate helper methods likeproduct.refresh()orproduct.delete(). -
Constraints are in the model.
@range(min: 1, max: 100)onmaxResultsmeans the generated SDK validates this client-side before making a network call.@length(min: 0, max: 10)on tags does the same. -
Pagination is declarative. The
@paginatedtrait on the service and operation tells the generator to produce auto-pagination helpers. In the TypeScript SDK, this becomes apaginateListProducts()function that returns an async iterator. -
Errors are typed. Each error structure maps to a specific HTTP status code. The generated SDK produces typed error classes that the caller can catch specifically.
The Trait System
Smithy's killer feature is traits. A trait is metadata attached to any shape (structure, operation, service, member) that modifies behavior or adds information. AWS defines hundreds of traits, but you can create your own:
@trait(selector: "operation")
structure rateLimit {
@required
requestsPerSecond: Integer
burstSize: Integer
}
@trait(selector: "structure > member")
structure sensitive {}
@rateLimit(requestsPerSecond: 100, burstSize: 200)
operation CreateProduct { ... }
structure UserProfile {
name: String
@sensitive
email: String
@sensitive
ssn: String
}
Our custom @rateLimit trait tells the SDK generator to add client-side rate limiting. The @sensitive trait (this one is built-in, actually) tells the generator to redact the value in logs and error messages. These aren't just annotations, they drive code generation.
Custom Validators
Smithy has a validation pipeline that runs on your model before code generation. We wrote custom validators to enforce our API design standards:
public final class PaginationValidator extends AbstractValidator {
@Override
public List<ValidationEvent> validate(Model model) {
List<ValidationEvent> events = new ArrayList<>();
for (OperationShape op : model.getOperationShapes()) {
// Every list operation must be paginated
if (op.getId().getName().startsWith("List")
&& !op.hasTrait(PaginatedTrait.class)) {
events.add(danger(op,
"List operations must use the @paginated trait. "
+ "Operation: " + op.getId()));
}
// All operations must have at least one error
if (op.getErrors().isEmpty()) {
events.add(warning(op,
"Operations should define at least one error type. "
+ "Operation: " + op.getId()));
}
}
// All structures with a 'createdAt' must also have 'updatedAt'
for (StructureShape struct : model.getStructureShapes()) {
boolean hasCreatedAt = struct.getMember("createdAt").isPresent();
boolean hasUpdatedAt = struct.getMember("updatedAt").isPresent();
if (hasCreatedAt && !hasUpdatedAt) {
events.add(danger(struct,
"Structures with 'createdAt' must also have 'updatedAt'. "
+ "Structure: " + struct.getId()));
}
}
return events;
}
}
This validator runs as part of smithy build and fails the build if any list operation isn't paginated or if timestamp audit fields are incomplete. It caught three missing pagination configurations in our models within the first week.
Building a Custom SDK Generator
The real power comes when you build your own code generator. Smithy's codegen framework gives you a model, a set of integrations, and you produce files. Here's a simplified version of how our TypeScript generator works:
public final class TypeScriptCodegenPlugin implements SmithyBuildPlugin {
@Override
public String getName() {
return "typescript-sdk";
}
@Override
public void execute(PluginContext context) {
Model model = context.getModel();
ServiceShape service = model.expectShape(
context.getSettings().getStringMember("service")
.map(ShapeId::from)
.orElseThrow(),
ServiceShape.class
);
TypeScriptWriter writer = new TypeScriptWriter();
// Generate client class
generateClient(writer, model, service);
// Generate types for all structures
for (StructureShape struct : model.getStructureShapes()) {
if (isInService(model, service, struct)) {
generateInterface(writer, model, struct);
}
}
// Generate operation functions
for (OperationShape op : getServiceOperations(model, service)) {
generateOperation(writer, model, op);
// Generate paginator if paginated
if (op.hasTrait(PaginatedTrait.class)) {
generatePaginator(writer, model, op);
}
}
// Generate error classes
for (StructureShape error : getServiceErrors(model, service)) {
generateErrorClass(writer, model, error);
}
// Write all files
writer.flushTo(context.getFileManifest());
}
}
The generated TypeScript client looks like this:
// Generated by smithy-typescript-codegen - DO NOT EDIT
import { HttpClient, RequestOptions } from './runtime';
export interface GetProductInput {
productId: string;
}
export interface GetProductOutput {
product: Product;
}
export interface Product {
productId: string;
name: string;
sku: string;
price?: number;
tags?: string[];
metadata?: Record<string, string>;
createdAt: Date;
updatedAt?: Date;
}
export class ProductNotFoundError extends ServiceError {
readonly name = 'ProductNotFound';
readonly statusCode = 404;
readonly productId?: string;
constructor(message: string, productId?: string) {
super(message);
this.productId = productId;
}
}
export class InventoryServiceClient {
private readonly http: HttpClient;
constructor(config: ClientConfig) {
this.http = new HttpClient(config);
}
async getProduct(
input: GetProductInput,
options?: RequestOptions
): Promise<GetProductOutput> {
// Client-side validation (from @required trait)
if (!input.productId) {
throw new ValidationError('productId is required');
}
return this.http.request({
method: 'GET',
path: `/products/${encodeURIComponent(input.productId)}`,
...options,
});
}
async *paginateListProducts(
input: ListProductsInput = {},
options?: RequestOptions
): AsyncGenerator<Product[], void, undefined> {
let nextToken = input.nextToken;
do {
const response = await this.listProducts(
{ ...input, nextToken },
options
);
yield response.products;
nextToken = response.nextToken;
} while (nextToken);
}
}
The paginator is automatically generated because ListProducts has the @paginated trait. The error classes are generated because the operation declares errors: [ProductNotFound, ValidationError]. The validation logic is generated from the @required and @range traits.
The Build Pipeline
Our complete pipeline:
smithy-model/
├── model/
│ ├── inventory.smithy
│ ├── orders.smithy
│ └── common.smithy
├── validators/
│ └── ApiStandardsValidator.java
├── smithy-build.json
└── build/
├── typescript/
├── python/
├── go/
└── rust/
The smithy-build.json orchestrates everything:
{
"version": "1.0",
"sources": ["model"],
"maven": {
"dependencies": [
"software.amazon.smithy:smithy-model:1.45.0",
"software.amazon.smithy:smithy-openapi:1.45.0",
"com.example:api-validators:1.0.0"
]
},
"plugins": {
"openapi": {
"service": "com.example.inventory#InventoryService",
"protocol": "aws.protocols#restJson1"
},
"typescript-sdk": {
"service": "com.example.inventory#InventoryService",
"packageName": "@example/inventory-sdk",
"packageVersion": "2.14.0"
},
"python-sdk": {
"service": "com.example.inventory#InventoryService",
"packageName": "example-inventory"
}
}
}
One smithy build command generates four SDKs, an OpenAPI spec, and validates the model against our standards. The generated SDKs are published to internal registries via CI. When an engineer changes the Smithy model and merges, all four SDKs are regenerated, tested against the running service, and published, automatically.
Was It Worth It?
The initial setup took about three weeks, one week learning Smithy, one week building the code generators, one week wiring up CI/CD. Since then:
- Zero drift between SDKs and the actual API
- New operations go from model change to published SDK in 12 minutes
- The custom validators have prevented 14 API design inconsistencies before they reached production
- Engineer onboarding to the SDK is trivial, the generated code is consistent across all services
Smithy's learning curve is steeper than OpenAPI's, and the Java-centric toolchain is a pain if your team isn't JVM-comfortable. But for multi-language SDK generation with type safety and validation baked in, nothing else comes close. The investment pays for itself within a quarter.