AWS Developer Tools Blog

Generate standalone types with Smithy

Without generated standalone types, a service that publishes events ends up with the payload defined manually in several places. The service has its own definition, and every consumer has their own definition. This is tedious, error-prone busywork for consumers. And as the service evolves, changes to events can expose bugs. A missing field or changed optionality breaks a consumer in production.

Smithy is an Interface Definition Language (IDL) for modeling services. You describe a service once, and code generators produce clients and servers for it in several programming languages. You do not need to handwrite the serialization code that turns your data into bytes and back again. Smithy can now generate types for any shape you choose, whether or not a service uses it in operation inputs or outputs.

That means the events your service publishes to a queue or topic get the same generated types and serialization that its input and output shapes already do. You publish one artifact, and every subscriber decodes your events with the definitions you wrote instead of a copy they maintain themselves.

Until now, Smithy code generators have always worked from service closures, which include every shape reachable from a service shape by walking its operations, resources, and members. With Smithy, you can now define a shape closure, a named set of shapes you declare in the model yourself. Because you define it explicitly, a shape closure can hold whatever shapes you choose.

Shape closures live in your model, so any generator in any language can use them. smithy-java 1.5.1 and smithy-typescript 0.52.0 support them today, with more languages to follow. In this post, we show how shape closures let you model events to generate standalone types that can be easily serialized and deserialized.

Model the service and its events

Consider a service for a bird-watching club whose members report sightings.

The following Smithy model defines the service:

$version: "2"

namespace com.example.audubon

use smithy.protocols#rpcv2Cbor

/// Tracks bird sightings reported by members of a bird-watching club.
@rpcv2Cbor
service BirdWatcher {
    version: "2026-08-05"
    operations: [ ReportSighting ]
}

/// Records a member's sighting of a bird.
operation ReportSighting {
    input := {
        @required
        birdId: Uuid

        @required
        sightedAt: Timestamp

        @required
        location: Coordinates

         // ... photo, bandCode, and other members
    }

    output := {
        @required
        sightingId: Uuid
    }
}

/// Where a sighting took place.
structure Coordinates {
    @required
    latitude: Double

    @required
    longitude: Double
}

/// A UUID, used for every identifier in this model.
@pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
string Uuid

One club member is a scientist. They want to know whenever a banded bird is spotted, so they can go out and try to recapture it. To support subscribers like this, the service publishes one event whenever a sighting is reported and another whenever a sighting is withdrawn. Model both events as ordinary structures and tag them:

$version: "2"

metadata shapeClosures = [
    {
        id: "com.example.audubon#events"
        includeBySelector: "structure[trait|tags|(values) = event]"
    }
]

namespace com.example.audubon

/// Published when a member reports a new sighting.
@tags(["event"])
structure SightingReported {
    @required
    sightingId: Uuid

    @required
    birdId: Uuid

    @required
    sightedAt: Timestamp

    @required
    location: Coordinates

    /// ... photoUrl and other members
}

/// Published when a sighting is withdrawn.
@tags(["event"])
structure SightingWithdrawn {
    @required
    sightingId: Uuid

    @required
    birdId: Uuid
}

Neither structure appears in any operation, so neither is in the BirdWatcher service closure. The shapeClosures metadata entry brings them into code generation anyway. It declares a closure named com.example.audubon#events and uses includeBySelector to include its shapes with a Smithy selector, a query expression that matches shapes in a model. This selector matches every structure carrying the tag event. (Closures can also select all shapes in a namespace with includeNamespaces. See shape closures for details.)

A closure must define at least one of includeNamespaces or includeBySelector, and its id must not collide with the ID of a real shape in the model. Membership then expands transitively, so matching SightingReported also pulls in Coordinates and the shared Uuid shape without requiring you to name them explicitly.

Generate the event types

Point the java-codegen plugin at the closure in smithy-build.json:

{
  "version": "1.0",
  "plugins": {
    "java-codegen": {
      "namespace": "com.example.audubon.events",
      "name": "AudubonEvents",
      "modes": ["types"],
      "closure": "com.example.audubon#events"
    }
  }
}

The required modes setting selects what gets generated. Passing types asks for data shapes only, with no client or server, and it is the one mode that works without a service setting. From there, closure names the shapeClosures entry to generate. Your build then runs the generator and compiles the result.

This produces SightingReported, SightingWithdrawn, and Coordinates in the com.example.audubon.events.model Java package, alongside the schema classes the runtime uses. The Uuid shape carries its @pattern constraint into those schemas. The plugin omits the operation input and output shapes because they are part of the service API, not the event types.

To generate the events next to a client or server, list types alongside client or server. That combined mode still requires the service setting, and it generates the standalone types along with the service.

Publish an event

Generated types serialize through any smithy-java codec. BirdWatcher already speaks Smithy RPC v2 CBOR, so the example encodes its events with CBOR too, keeping one wire format across the service and the events it publishes. The following class serializes and publishes a sighting event to Amazon Simple Notification Service (Amazon SNS):

import com.example.audubon.events.model.Coordinates;
// ... other imports

public class SightingPublisher {
    private static final Codec CODEC = Rpcv2CborCodec.builder().build();

    private final SnsClient sns;
    private final String topicArn;

    public SightingPublisher(SnsClient sns, String topicArn) {
        this.sns = sns;
        this.topicArn = topicArn;
    }

    public void publish(SightingReported event) {
        // Serialize the event to CBOR. 
        // The builder already validated its input.
        ByteBuffer payload = CODEC.serialize(event);

        // Base64-encode for the SNS UTF-8 message body.
        byte[] bytes = new byte[payload.remaining()];
        payload.get(bytes);
        String message = Base64.getEncoder().encodeToString(bytes);

        sns.publish(PublishRequest.builder()
                .topicArn(topicArn)
                .message(message)
                .build());
    }

The generated builders handle validation, so a malformed event fails in the producer, rather than in a subscriber that can do nothing about it. They also carry the model’s types into Java. Smithy’s Timestamp becomes a java.time.Instant, and the codec owns the wire representation. That removes the timestamp format disagreements hand-rolled encoders tend to produce.

Subscribe to the event

Subscribers consume types generated from the same closure, so there is only one definition of the payload to keep up to date. Decoding follows the publisher’s steps in reverse:

import com.example.audubon.events.model.SightingReported;
// ... other imports

public class BandedBirdNotifier {
    private static final Codec CODEC = 
        Rpcv2CborCodec.builder().build();

    private final BandDetector bandDetector;
    private final SmsNotifier smsNotifier;

     // ... constructor

    /**
     * Handles one message body received from the topic subscription.
     */
    public void onMessage(String message) {
        byte[] payload = Base64.getDecoder().decode(message);
        SightingReported event =
            CODEC.deserializeShape(payload, SightingReported.builder());

        // Skip sightings whose band was already read 
        // and those with no photo to inspect.
        if (event.getBandCode() != null || event.getPhotoUrl() == null) {
            return;
        }

        if (!bandDetector.hasBand(event.getPhotoUrl())) {
            return;
        }

        /// ... notification logic
    }
}

deserializeShape takes the generated builder and returns a fully typed event, so the notifier reads event.getLocation().getLatitude() instead of indexing into a map and casting. Optional members come back as null when absent, which is why the handler checks getBandCode() and getPhotoUrl() before using them. The members the model guarantees, such as getLocation(), need no such check.

Try it yourself

The bird-watching model from this post is a runnable example in the smithy-java repository. To run it, you need at least Java 21. Download with the Smithy CLI and build it:

smithy init -t closure-types --url https://github.com/smithy-lang/smithy-java.git
cd closure-types
gradle build

The example goes further than this post does. Its model gives Sighting a full resource lifecycle, and four subprojects build from it, one for each way to configure the plugin.

  • types generates the event types on their own, which is the package you publish.
  • server uses combined mode to generate the service beside those events, implements the operations, and publishes to an SNS topic.
  • client generates a client and no events, since a caller has no use for them.
  • consumer generates nothing at all, depending on types instead, and is the subscriber from this post.

In this post, I showed how to hand your subscribers the same event definitions you publish with. Tag the event structures, declare a shape closure over them in shapeClosures metadata, and generate types for that closure. Producers and subscribers then share one generated definition of the payload, and the serialization and validation code that you would otherwise write by hand comes from the model.

Because the closure lives in the model, it is not tied to any one language. Any tool that reads the model can use it. smithy-typescript generates types from a shape closure the same way smithy-java does, and other generators will follow.

To go deeper:

Let us know how you are using shape closures, and if you have any questions or feedback, leave us a comment below or open an issue in the smithy-java repository on GitHub.

TAGS: ,
Jordon Phillips

Jordon Phillips

Jordon is a senior software development engineer on the Smithy team at AWS. He enjoys working on projects and tools that aim to improve the developer experience.