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:

  1. Resources are first-class. Product is 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 like product.refresh() or product.delete().

  2. Constraints are in the model. @range(min: 1, max: 100) on maxResults means the generated SDK validates this client-side before making a network call. @length(min: 0, max: 10) on tags does the same.

  3. Pagination is declarative. The @paginated trait on the service and operation tells the generator to produce auto-pagination helpers. In the TypeScript SDK, this becomes a paginateListProducts() function that returns an async iterator.

  4. 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.