Most Popular

View all

Recent articles

The Master YAML: A Manifest of Your Entire Architecture

The Master YAML: A Manifest of Your Entire Architecture As Arcadia Editions grows, no single person knows the whole system anymore. The big picture scatters across dozens of repositories. ZenWave architecture manifest pulls it back into one place with every domain, service and contract and how they connect, without copying any of it. And as you work with your API contracts, the pipeline keeps it current: adding services, artifacts, versions... So this manifest and ZenWave Platform keeps your whole architecture connected and navigable for humans, tools and agents. As Arcadia Editions grows, no single person knows the whole system anymore. Services multiply, each team comes to know its own piece well but nobody knows how the whole system works. What’s missing isn’t more documentation. What is missing is a complete landscape, a self-contained architectural world model of the whole system. Documenting a REST API endpoint or an asynchronous message is easy. For that we have OpenAPI and AsyncAPI, along with other standard API specifications. What is missing is a manifest that connects all the pieces together. What Arcadia, and any other real org, needs is the architecture itself in a form both people and tools can read, a structured, machine-readable representation of what the system derived from the lifecycle of the API contracts themselves. That is what a world model is. Every service, every contract, every event and schema, described once in one consistent structure, and connected, so you can navigate from bounded context to bounded context to an API to a schema in a single step. It is the model itself. The Master YAML is that map written down. One file, zenwave-architecture.yml, that knows where everything is, the index of the whole architecture, holding not the artifacts but the pointers to them. It is hierarchical, from domain to subdomain to service to contracts, so the tree reads like the business itself. It is git-native, so it versions, diffs and reviews right alongside the code. And it is tool-agnostic, because anything that reads YAML can consume it. From that one file, a whole architecture map can be derived. You generate an EventCatalog website from it, feed an LSP that navigates across services, provision infrastructure, know your consumers, validate contracts. The Master YAML is to your architecture, the manifest that makes the whole thing navigable. You could call a single YAML file a poor man’s database, and in a sense it is. But it is the right call for the source of truth, on two conditions this series already meets. The integrity has to live in a schema and in the tooling that reads it, not in the format. And the file has to be machine-maintained rather than hand-curated once you are past a handful of services. You can start it by hand, but you do not keep it alive by hand: the tooling maintains it for you, writing a service in when you add its artifacts and bumping the version when you release. Meet those two, and the Master YAML stops being a poor man’s anything and becomes what it should be, the one place your whole architecture is written down and everything else is derived from. What the manifest actually is The manifest is an index of all your service artifacts, properly cataloged. It describes the architecture as a tree of domains, subdomains and services, and for each service it points at the artifacts that service already owns, the ZDL model, the OpenAPI contract, the provider and client AsyncAPI contracts, together with the documents that travel alongside them like the SUMMARY and the CHANGELOG. Every one of those entries is a pointer to a file that lives in the service’s own repository, the very same file the code is generated from, so the manifest never restates a schema or a channel or a topic. It only says where each of them lives and how they fit into the larger whole. zenwave-manifest, an open-source Kotlin Multiplatform library, contains not only the json-schema to validate your architectural manifest, but the rules to resolve each artifact type from different sources: workspace, git, http, maven, artifactory, apicurio-registry… and utility functions to fetch the actual content of each file. Here is the Orders Checkout corner of the Arcadia Editions manifest. # yaml-language-server: $schema=https://schemas.zenwave360.io/zenwave-architecture/latest/schema.json config: title: "Arcadia Editions - Event-Driven Retail Architecture" version: 0.0.1 groupIdExpression: "com.arcadiaeditions.${service.id}" artifactIdExpression: "${artifact.fileNameWithoutExtension}" contentResolution: - workspace - git sources: workspace: basePathExpression: "../${service.repository}" git: provider: github server: "https://github.com" contentUrlExpression: "${server}/arcadia-editions/${service.repository}/raw/main/${content.path}" # [...] domains: "orders": id: "orders" name: "Orders" description: "Commercial order creation, confirmation, and cancellation" subdomains: "checkout": id: "orders.checkout" name: "Checkout" description: "Customer checkout, order commitment, and commercial orchestration" services: "orders-checkout": id: "orders.checkout.orders-checkout" repository: "orders-checkout-api" version: "0.0.1" name: "Orders Checkout" description: > Owns checkout flow, order lifecycle, and the handoff from purchase intent to confirmed order docs: summary: SUMMARY.md content: EVENT_CATALOG.md changelog: CHANGELOG.md artifacts: - type: zdl path: "domain-model.zdl" - type: asyncapi path: "asyncapi.yml" - type: asyncapi-client path: "asyncapi-client.yml" - type: openapi path: "openapi.yml" consumers: - "payments/payment-processing/payments-processing" - "fulfillment/shipping/fulfillment-shipping" - "notifications/customer-communications/notifications-consumer" Read from the top down it is the whole company in miniature. The Orders domain contains a Checkout subdomain, the Checkout subdomain owns the Orders Checkout service, and that service points at the four contracts from the previous articles and the two documents that describe it. Repeat that for Catalog, Inventory, Payments, Fulfillment and Notifications and the entire Arcadia Editions architecture sits in one file you can read start to finish in a couple of minutes. It’s not only a map of pointers, but a connected graph where services know their consumers. When a service wants to consume, as a client, messages from another service, the API pipelines will automatically add to the list of consumers of the target service. Written out across every service, those pointers are the architectural topology of Arcadia Editions. You can start at any service and walk outward to see who reacts to it and what it reacts to in turn, which is the question you actually ask when you are about to change an event or trace an incident back to its origin. The connections were always there in the individual AsyncAPI contracts, but they were spread across a dozen repositories where no one could see them at once. The manifest gathers them into a single graph you can follow. One file, resolved from wherever the artifacts live A pointer is only useful if something can follow it, and the same manifest can be resolved from more than one place depending on where you are standing. When you are working locally it resolves each artifact against the repositories checked out in your workspace, so the model you are editing is the one it reads. In a pipeline, or from a consumer who has never cloned anything, it resolves the same entries against the published artifacts instead, reaching for them in Git, in Apicurio Registry, or in your Maven repository in a defined order until it finds them. config: contentResolution: - workspace - git - apicurio That is why the manifest is operational rather than documentation. It describes the architecture once, and it resolves to your local checkouts while you are building and to the published products from the previous article once they are released, without the description itself having to change. The same file is the developer’s map and the consumer’s map, and neither of them is looking at a copy. Navigable by humans, tools and agents Because the manifest is a real file with a published schema, the yaml-language-server line at the top gives you validation and completion while you write it, and because is plain yml, any tools can read from it, including your AI agents. Underneath ZenWave Platform lives zenwave-manifest a small Kotlin Multiplatform library that resolves the tree and its artifacts on both the JVM and JavaScript, which is what lets the ZenWave IDE and the tooling around it navigate the whole architecture from this one entry point rather than crawling repositories one at a time. The Model compounds in value as it grows The manifest is worth building because its value compounds as more things learn to read it: Today: humans navigate it to understand the system Tomorrow: the EventCatalog website makes it visible to the whole organization Later: coding agents use it as context to read and fetch contents, without hallucinating contracts The model does not change in nature — only the consumers of it change Build the model once. Use it everywhere. That is exactly what the ZenWave Platform is built to do, feed on this architectural manifest and turn it into something every kind of consumer can reach: It starts with zenwave-manifest, an open-source library that reads the manifest and loads the individual artifacts it points at. On top of that library sits an open-source LSP, so an editor can navigate the whole architecture the way it navigates a single codebase. And on top of the LSP sits an MCP server, so an agent reaches the same model, through the same resolved graph, that a human does. One manifest at the bottom, and a library, a language server and an agent interface stacked on it, each drawing from the same source of truth. From here the manifest stops being a description and starts being a starting point. Because it already knows every contract and every document each service owns, it can drive the next steps of the series directly, beginning with Publishing to Apicurio and Generating an Event Catalog, where the same registry the manifest resolves against becomes the place the whole organization goes to discover what exists. Originally published at ivangsa.com.

Treat Your Domain Models and APIs as Products

Treat Your Domain Models and APIs as Products A domain model and the APIs it generates are not implementation details of a service. They are the contracts every consumer depends on. Treating them as products means designing them, validating them, versioning them, publishing them, and evolving them with the same discipline as any software artifact. ⚠️ This article is part of Arcadia Editions, a project being built in public. Some of the links point to workflows and articles that are still in progress. They will become available as the series evolves. 🏗️ An API contract is the densest context you have about a system. In a handful of files it says what the system does, what it accepts and returns, what it publishes, and what it promises to keep stable, and it says all of it in a form that is precise and machine readable. Little else you publish concentrates that much meaning in so little space, which is exactly why it deserves to be treated as a product rather than as a file that falls out of the build. That was always valuable, and in the agentic era it becomes decisive, because the reader is no longer only a human. Today the contract is increasingly consumed by an AI coding agent, and an agent needs exactly what a good contract already provides, explicit schemas, real examples, semantic descriptions and deterministic behavior it can build against without guessing. A poor API forces the agent to infer intent from names and hope it guessed right, while a good one states that intent outright. The better your API is as a product, the better it is for the machines reading it too. That claim about the contract being the densest context you have is true of what you publish, but there is something denser still behind it. In Arcadia Editions the API is not written by hand, it is derived from a ZDL domain model, and that model carries even more meaning than the contract does, because the OpenAPI and the AsyncAPI are both generated from it. Every organization already stands on public APIs and their specifications, which is the common ground this article assumes, and some will want to go one step further, to the model those specifications come from. Either way the order is the same. The model comes first, the contract is its published projection, and that hierarchy is the spine of everything below. When people say treat your API as a product they usually picture a public REST API like Stripe or Twilio, but the idea is much broader than that. It does not matter whether you expose REST with OpenAPI, publish events with AsyncAPI, or define a gRPC service, because the technology is not the point. The point is that another piece of software depends on the contract you publish, and that contract becomes the boundary between teams, between systems, and sometimes between companies. The moment something starts depending on it, you have a product, whatever the transport underneath. And it stays a product even as everything behind it changes. You can rewrite the service, move to a different database, split a monolith into several services, or migrate from Kafka to another broker, and none of it should reach the consumer, because what they bought is the contract and not the code that happens to satisfy it today. That is the whole reframe. The service is not the product. The domain model and the contracts it generates are the product. Why treat the contract as a product? Every product has customers, and an API is no different. Sometimes those customers are external developers integrating with your platform, more often they are other teams inside your own company, and increasingly they are AI coding agents generating clients, tests and whole applications from a machine readable spec. In Arcadia Editions the customers of the Orders Checkout contract are the other bounded contexts that react to its events and the services that call its REST surface, together with every future integrator who has not shown up yet. They all expect the same things, something easy to discover, easy to understand, reliable to integrate with, stable across releases, and documented when a question comes up. None of those are technical concerns. They are product concerns. Thinking this way changes the questions you ask. Instead of asking whether an endpoint works you start asking whether you would enjoy integrating with it, and instead of treating a breaking change as an ordinary code edit you treat it as a product decision, because that is what it is. Versioning, deprecation policies, migration guides and compatibility guarantees stop being optional documentation you might get around to and become part of the product itself. The reframe holds regardless of transport, because an OpenAPI document, an AsyncAPI document and a gRPC definition all describe a capability that somebody else wants to consume. The shape differs, the product does not. What operating a contract as a product looks like Once you accept that the contract is the product, the next question is how you operate it like one. The answer is not buying an API Gateway or an API Management platform, because those manage traffic to an API that already exists. Operating the contract as a product means building a lifecycle around it, the same lifecycle any released software component has, running from design through quality, versioning, publishing and discovery to the artifacts you derive from it. In Arcadia Editions that lifecycle lives in the API repository itself, right next to the model the contract comes from. ZDL Model │ ▼ OpenAPI / AsyncAPI │ ┌───────┴────────┐ │ │ Spectral Version │ │ └───────┬────────┘ ▼ Package as Maven ▼ Publish Artifactory ▼ Register Apicurio ▼ Consumers download API ▼ Generate SDKs / Docs / Tests It starts with the model Everything starts with the model, because the API contract is the first artifact you publish but it is not the first artifact you own. For Orders Checkout the contract is not even written by hand, it is generated from the ZDL domain model that was the whole story of the previous article, DSL Modeling for APIs. That makes the domain model the first product in the chain, the single source that the contract, the generated code and even the platform all derive from, and it makes the API contract the product you publish from it. The OpenAPI for the REST surface and the AsyncAPI for the events both come out of that one model, so there is a single source of truth from the very first commit, and everything in this article is about what you do with it once the contract exists. The specification language is not the interesting part. Making the model the thing everything else derives from is. Give it executable quality standards Products have quality standards, and API contracts should have them too. Naming conventions, standard error responses, security requirements, event naming rules, CloudEvents conventions, Kafka bindings and versioning policies are what give every API in the organization the same look and feel, and in Arcadia Editions the reusable parts of that already live in one place, api-contract-commons, so no single API has to reinvent them. The mistake is to leave those rules in a wiki where nothing enforces them. They should be executable instead, and Spectral is where that happens for contracts, because it lets you express the standard as lint rules that run on every pull request the same way unit tests do. If the contract does not comply the build fails, and the standard stops being a suggestion. Version the contract, not the URL Every product has releases, and an API should too, but versioning is much more than putting /v1 in a path. It means deciding what counts as a breaking change, writing down the compatibility rules, communicating deprecations, and giving consumers enough time to move. Take the OrderConfirmed event that Orders Checkout publishes. Adding a new optional field to it is a compatible change, so consumers can pick it up whenever they like and nothing breaks in the meantime, whereas renaming a field, removing one, or tightening a type is a different thing entirely, a new major version that has to be announced with the old one kept alive long enough for everyone to migrate. That promise is what the CHANGELOG.md in the API repository records, one entry per version, so a consuming team can read it and know whether a bump is safe to take. The version belongs to the contract, and the implementation simply satisfies it. Publish it like any other artifact Imagine writing a Java library and never publishing it anywhere, expecting every consumer to copy the source out of your repository by hand. That is surprisingly close to how many organizations still handle APIs, with the spec sitting in a Git repository while everyone fetches a raw URL. The contract should be published where consumers already expect to find the things they depend on, and for a Java shop that place is a Maven artifact. Packaged that way the OpenAPI or AsyncAPI specification becomes a versioned component with metadata and dependency management, published automatically to Artifactory or Maven Central like any other library, and a consuming project pulls in a specific version and lets Dependabot or Renovate propose the upgrades as they land. At that point the API has stopped being a YAML file in a repository and become a released product. Automate the release Products deserve release pipelines. A GitHub Actions workflow in the API repository can regenerate the contract from the model, validate it with Spectral, build the documentation and the SDKs, package the specification as a Maven artifact, and publish the whole thing without a single manual step. The detail worth pausing on is what that pipeline is actually doing, because it is not deploying software, it is releasing the API product. The service deployment and the contract publication are related but they are not the same event, they answer to different triggers and different consumers, and keeping them separate is what lets the contract move at the pace of a product instead of the pace of a deployment. ZDL │ Generate Contract │ ┌─────────┴─────────┐ │ │ ▼ ▼ API PRODUCT SERVICE Spectral Compile Version Unit Tests Package Docker Publish Deploy Registry Kubernetes Consumers Runtime Make it discoverable Publishing is only useful if people know where to look, and a registry gives consumers one place to discover the APIs that exist, browse versions, inspect schemas and see how the systems talk to each other. Arcadia Editions publishes its contracts to Apicurio Registry, which gets its own article later in this series, Publishing to Apicurio and Generating an Event Catalog, so here it is enough to say what it buys you. It gives contracts a permanent home instead of scattering YAML across dozens of repositories, and it changes the first question a team asks from what they should call their new API to whether that API already exists. That question alone prevents a surprising amount of duplication. Keep it compatible Quality is one thing and stability is another, because a contract can be perfectly well formed and still break the consumers that already depend on it. Before a new version is published, a compatibility check should confirm that existing consumers will keep working, and this matters most in event driven architectures, where schemas evolve on their own timeline and producers and consumers are deployed independently and rarely at the same moment. A schema registry can enforce those rules automatically, refusing a version that would break compatibility, so that publishing an update becomes a safe and boring event instead of a risky one. Consumers learn that upgrading is routine, and that trust is most of what makes an API pleasant to depend on. Derive, do not duplicate Once the contract is the authoritative source, every artifact you maintain by hand next to it becomes a place where things can drift apart. The alternative is to derive them, generating the documentation, the SDKs, the client libraries, the server stubs, the mocks and the tests directly from the specification, so there is only ever one source of truth and everything else is a projection of it. This is the point where treating the API as a product pays back the effort, because it is exactly the mechanism that keeps an implementation from drifting away from its spec. In Arcadia Editions those derived services are built with ZenWave SDK, which is the subject of a later article on building the Spring Boot and Kotlin backend, and the same idea reaches past the code all the way to the platform, where the Kafka topics and their configuration can be provisioned from the very same contract. The implementation evolves, the generated artifacts evolve with it, and nothing drifts because nothing is maintained twice. The tooling only supports the lifecycle The interesting part of all this is that no single tool is the story. OpenAPI and AsyncAPI define the product, Spectral validates its quality, GitHub Actions automates its release, Maven packages it, Artifactory distributes it, and Apicurio Registry makes it discoverable. Each one supports a single stage of the lifecycle, and it is only together that they create the experience of operating an API as a real product rather than treating it as one more file in a repository. Swap any of them for an equivalent and nothing about the idea changes, which is the surest sign that the idea, and not the toolchain, is what matters. The pipelines we build for Arcadia Editions This is where the idea stops being an essay. Everything above is built in the open in the Arcadia Editions GitHub organization, and rather than copy the same YAML into every API repository, the pipelines live once as reusable workflows in api-product-workflows, and each API repository calls them. The pipelines are a product too, versioned and reused the same way the contracts are. Each one owns a single stage of the lifecycle, and the rest of this series walks through them one at a time. Some are still being built, so a few of the links below point at work in progress, which is the honest state of anything built in public. Contract CI regenerates the OpenAPI and AsyncAPI from the ZDL model on every push and runs Spectral over the result, so a hand-edited or non-conformant contract fails the build before anyone downstream ever sees it. In orders-checkout-api it is just a few lines that call the shared workflow. Contract release packages the specification as a versioned Maven artifact and publishes it, turning the contract into a dependency consumers pull in like any other library. Registry publishing pushes each contract to Apicurio so the whole organization has one place to discover what exists, which is the subject of Publishing to Apicurio and Generating an Event Catalog. Compatibility gate checks a new version against the ones consumers already depend on and refuses anything that would break them, so publishing an update stays a boring event. Code derivation generates the strongly typed clients, servers, DTOs and tests with ZenWave SDK, which is where building the Spring Boot and Kotlin backend picks the story up. Platform provisioning drives the Kafka topics, schemas and their configuration from the very same contract with Terraform, the subject of provisioning infrastructure from AsyncAPI. Put together, the first pipelines close the gap between the contract and the code, and the last one closes the gap between the contract and the platform it runs on. It is one contract, and the pipelines are how it stays the single thing everything else is derived from instead of one more file that quietly drifts. The model and its contracts are the product Thinking about APIs as products is useful, but it is worth being even more precise about it. The model is where the meaning lives and the contract is how that meaning reaches everyone else, and everything else exists to implement the promise they make. Once both are first class artifacts something quietly changes, because versioning them, validating them, publishing them, discovering them, generating code from them and evolving them without breaking consumers stop feeling like separate activities and start to look like different parts of one product lifecycle. That is the real shift in mindset. It is not about treating your services as products. It is about treating the models you design and the contracts between your systems as the products they already are, because those are what everyone else, and every agent, actually builds on. Originally published at ivangsa.com.

DSL Modeling for APIs: Generate OpenAPI and AsyncAPI from ZDL

DSL Modeling for APIs: Generate OpenAPI and AsyncAPI from ZDL Writing YAML by hand is no fun, but you can generate your OpenAPI and AsyncAPI definition files from a Domain Specific Language instead. With ZenWave SDK you convert a compact ZDL model into those contracts, saving time and effort while keeping your APIs aligned with best practices and standards. Turning ZDL into APIs was one of the first things ZenWave SDK ever did, back when we still wrote all of it by hand. The idea was that you spend your time modeling the domain and then let the APIs come out of that model, because an API contract is structured information too, and going from one structure to another is the kind of work a generator should be doing instead of you. And since a DSL is built to be compact, it says far more with far less than YAML ever will. Friends don’t let friends write YAML by hand. The capability grew release after release, funded by the time it kept saving us. Somewhere along the way it became simpler to teach the toolkit a new feature than to keep writing it out by hand, whether that was file upload and download or any of the other niche corners of these specs. Because if you write it by hand you write it by hand every time, but once it lives in the DSL and the tooling you have solved it for every iteration that comes after. Today it covers almost everything you need in a normal enterprise application, and when you want to shape the output beyond that, OpenAPI overlays are right there to do it, which keeps everything easy to sync. You edit the model and you generate, and if there is something you need to change, you don’t reach in and change it by hand, you write an overlay and it lands on top of whatever was generated for you. None of this is really surprising once you see what is going on underneath. We are only generating structured things out of other structured things, and it works so well because they are mostly mirrors of each other. The DSL ends up being a mirror of what the application already is, an internal model on one side and the external APIs on the other, the two almost reflections. Everything the API needs is already in the model At this point the model already carries most of what an API needs. We know the shape of the aggregate, its entities and the trees they form, and the value objects that live inside them. We know the commands that expose actions to the outside world, and we know the domain events, the ones we publish and the ones we react to. All of that is already written down. Adding a few annotations to the model, marking the rest and async patterns, does two things at once. They document how these internal concepts connect to the external world of the APIs, and from the same annotations we generate a complete draft of those APIs. Configuring the generators Everything we show from here on is part of one file, the domain-model.zdl for Orders Checkout. The config section is where the model says what should be generated. For Orders Checkout we want three API artifacts: config { id "urn:com.arcadiaeditions:orders:checkout" title "Arcadia Editions - Orders Checkout" basePackage "com.arcadiaeditions.orders.checkout" plugins { /** Generates an AsyncAPI v3 specification from this ZDL model. */ ZDLToAsyncAPIPlugin { id "urn:com.arcadiaeditions:orders:checkout:asyncapi" applicationExtensions """ x-application-bindings: x-principal: orders_checkout x-clientId: orders_checkout x-groupId: orders_checkout """ schemaFormat avro idType integer idTypeFormat int64 avroPackage com.arcadiaeditions.orders.checkout.events.avro includeCloudEventsHeaders true includeKafkaCommonHeaders true asyncapiOverlayFiles "https://raw.githubusercontent.com/arcadia-editions/api-contract-commons/refs/heads/main/asyncapi-overlay.yml" targetFile "./asyncapi.yml" } ZDLToAsyncAPIClientPlugin { id "urn:com.arcadiaeditions:orders:checkout:asyncapi:client" applicationExtensions """ x-application-bindings: x-principal: orders_checkout x-clientId: orders_checkout x-groupId: orders_checkout """ title "Arcadia Editions - Orders Checkout - AsyncAPI Client" asyncapiOverlayFiles "https://raw.githubusercontent.com/arcadia-editions/api-contract-commons/refs/heads/main/asyncapi-client-overlay.yml" targetFile "./asyncapi-client.yml" } /** Generates an OpenAPI 3.0 specification from this ZDL model. */ ZDLToOpenAPIPlugin { idType integer idTypeFormat int64 targetFile "./openapi.yml" } } } ZDLToOpenAPIPlugin generates the REST contract. In this service that means openapi.yml. ZDLToAsyncAPIPlugin generates the provider-side AsyncAPI contract for the messages Orders Checkout owns. In this model that means the domain events it publishes: OrderCreated, StockUnavailable, OrderConfirmed, and OrderCancelled. ZDLToAsyncAPIClientPlugin generates a client-oriented AsyncAPI contract for the messages this service consumes from other bounded contexts. That gives the implementation side a clear view of the incoming event-driven surface without mixing it into the provider contract. The Avro and header options are also part of the API design. They say that event payload schemas should be generated in Avro form, and that the AsyncAPI contract should include CloudEvents and common Kafka headers. Those are not domain rules, but they are still architectural decisions worth making explicit. You will also notice the applicationExtensions block. Those are extra pieces of information we attach to the application itself, like the principal, the client id and the group id, and they get carried into the generated contract as extensions. On their own they are just metadata sitting in the file, but they become useful once an overlay reads them to fill in other parts of the spec, for example the Kafka bindings. That is what asyncapiOverlayFiles is for. An overlay is where you customize the generated contract without editing the generator, following the OpenAPI Overlay specification, and ZDL applies the same idea to AsyncAPI. For Orders Checkout the overlays live with the rest of the shared contract tooling, asyncapi-overlay.yml for the provider contract and asyncapi-client-overlay.yml for the client one. Declaring external APIs Orders Checkout does not work in isolation, it reacts to what happens in other bounded contexts. That is what the apis section is for: apis { asyncapi client PaymentsProcessingApi "https://raw.githubusercontent.com/arcadia-editions/payments-processing-api/main/asyncapi.yml" asyncapi client CatalogInventoryApi "https://raw.githubusercontent.com/arcadia-editions/catalog-inventory-api/main/asyncapi.yml" } This tells the model that Orders Checkout is a client of those AsyncAPI contracts. That matters because consuming a third-party event is not the same thing as exposing a command we own. A bounded context is the provider of its own behavior and the client of behavior owned elsewhere. So when we later write this: @asyncapi(api: PaymentsProcessingApi, channel: "payment-authorized-event-v1") @transition(from: CREATED, to: CONFIRMED) confirmOrder(ConfirmOrderInput) Order withEvents OrderConfirmed we are saying something precise: Orders Checkout handles confirmOrder when it receives a message from the payment-authorized-event-v1 channel defined by the Payments Processing API. The command belongs to Orders Checkout, but the triggering fact comes from another bounded context. All of that is captured in one small annotation. Generating OpenAPI from REST decorators REST starts at the service level. @rest("/orders") service OrdersCheckoutService for (Order) { @post @transition(to: CREATED) startOrderCheckout(StartOrderCheckoutInput) Order withEvents [OrderCreated | StockUnavailable] } @rest("/orders") gives the service a base path. @post exposes startOrderCheckout as a REST operation. Since this is a create-style command, POST /orders is a natural first draft. The input type becomes the request body. The returned Order becomes the response schema. The input itself is still modeled in ZDL: input StartOrderCheckoutInput { items String[] minlength(1) } From that, the OpenAPI generator has enough information to create a request schema with an items array and validation metadata. ZDL gives us more REST decorators when the API needs them: @get for read operations @post for create commands or search-style operations @put for replacement updates @patch for partial updates @delete for delete operations @paginated for paginated list responses @fileupload and @filedownload for binary payloads They can be used in shorthand form, like @get("/{orderId}"), or with options such as path, status, params, and operationId. @get({path: "/somepath", status: 200, params: {search: String}, operationId: "someOperationId"}) For this first Orders Checkout API, we only need one REST entry point: start the checkout. The rest of the workflow is event-driven. Generating AsyncAPI from events Events are modeled separately from commands because they mean something different. A command asks the system to do something. An event says something already happened. The Orders Checkout model publishes its own facts: @asyncapi({ channel: "order-created-event-v1", topic: "orders-checkout.order-created.event.avro.v1" }) event OrderCreated { orderId String version Integer } @asyncapi({ channel: "order-confirmed-event-v1", topic: "orders-checkout.order-confirmed.event.avro.v1" }) event OrderConfirmed { orderId String version Integer confirmedAt Instant } The @asyncapi decorator gives each event a channel and a topic, and both follow the naming convention we use across Arcadia Editions. The channel is the event name in kebab case with a version suffix, like order-created-event-v1, and the topic spells the same thing out in full, orders-checkout.order-created.event.avro.v1, which reads as the owning bounded context, the event itself, the kind of message, the payload format, and the schema version. From that, the generator can create the AsyncAPI schema, message, channel, and send operation. But there is an important rule: only emitted events belong in the generated provider AsyncAPI contract. Defining an event type is not enough. The event must be connected to a command with withEvents: startOrderCheckout(StartOrderCheckoutInput) Order withEvents [OrderCreated | StockUnavailable] confirmOrder(ConfirmOrderInput) Order withEvents OrderConfirmed cancelOrder(CancelOrderInput) Order withEvents OrderCancelled This keeps the contract honest. The service only publishes events that its own commands can actually emit. Decorating async inputs The same @asyncapi decorator is used for incoming messages, but the meaning changes depending on whether we reference an external API. Here Orders Checkout listens to facts from other bounded contexts: @asyncapi(api: PaymentsProcessingApi, channel: "payment-authorized-event-v1") @transition(from: CREATED, to: CONFIRMED) confirmOrder(ConfirmOrderInput) Order withEvents OrderConfirmed @asyncapi(api: CatalogInventoryApi, channel: "stock-released-event-v1") @transition(from: [CREATED, CONFIRMED], to: CANCELLED) cancelOrder(CancelOrderInput) Order withEvents OrderCancelled Because both annotations specify api, Orders Checkout is acting as a client of those APIs. It consumes messages from Payments Processing and Catalog Inventory. If there were no external api, then the command would be part of the API Orders Checkout provides. That is the distinction between provider and client: a provider consumes commands addressed to it and publishes its own domain events a client consumes events or sends commands defined by another bounded context That distinction keeps the generated AsyncAPI files clean. Provider contracts describe what this service owns. Client contracts describe what this service depends on. The decorator vocabulary At this point the model has a small but expressive API vocabulary: @rest("/orders") says a service has a REST surface. @get, @post, @put, @patch, and @delete say how a service command appears in OpenAPI. @asyncapi({ channel, topic }) on an event says how this bounded context publishes a fact. @asyncapi(api: SomeExternalApi, channel: SomeChannel) on a command says the command is triggered by a message from another API. @transition keeps the API operation tied to the aggregate lifecycle. withEvents connects commands to the facts they may publish. That last pair is what ties everything together. The API here is not just a transport description, it is attached to the behavior of the model, so a REST operation or an async listener is never floating on its own, it is connected to a command, a state transition, and the events that can follow. The first draft is complete, not final After generation, Orders Checkout has three useful contract artifacts: openapi.yml for the REST operation that starts checkout asyncapi.yml for the domain events Orders Checkout publishes asyncapi-client.yml for the external messages Orders Checkout consumes That is already a complete draft. It has operations, schemas, messages, channels, topics, and the vocabulary of the domain. But it is still a draft, and this is the moment to review names, payloads, channels, status codes, error shapes, headers, and compatibility rules. ZDL gets us to a coherent first version quickly, and API review turns that first version into a stable contract. What matters is the direction we worked in. We did not start by hand-writing YAML and then try to remember which business rule it came from. We started with the model, its aggregate, commands, transitions and events, and the generated API contracts preserve that model as it moves into OpenAPI, AsyncAPI, Avro schemas, adapters, documentation, and tests. Generation does not do the design for you. It just keeps the design you already made from getting lost on the way down. Originally published at ivangsa.com.

AsyncAPI: Which Reference Strategy to Use and Why

AsyncAPI: Which Reference Strategy to Use and Why AsyncAPI's $ref lets a client spec point to a provider spec without duplicating it. When that spec drives both application contracts and infrastructure provisioning, the URL behind the $ref is an important decision to answer. A pinned version, a environment alias, or the tip of main: each offer different trade-offs. The previous post introduced the two-spec pattern: each application models two different API definitions: a provider spec with what the application owns a client spec that references uses via $ref. The reference is a URL. At some point that URL must resolve to an actual document. The question is: which document? AsyncAPI as a contract and infrastructure spec An API first such as AsyncAPI serves many purposes at once. As an application contract, it describes what an application does: the events it produces, the commands it accepts, the messages it sends. The client spec references the provider spec to validate compatibility at authoring time and in CI. If the provider changes a schema in a breaking way, the client’s build should fail before anything reaches production. As an infrastructure spec, it describes what the messaging platform should look like: topic addresses, partitions, schemas, ACLs. A Kafka provisioning pipeline reads the provider spec and ensures the topic exists with the right configuration. The spec is the desired state of the broker. Both uses depend on this reference URL source. And applications evolve over time and in parallel. Which means that a particular client maybe developing a feature that references a topic that is still in development, not released or provisioned to prod. This introduces different strategies about which reference target to use. Which question do you want to answer? Every reference strategy answers a specific question. StrategyExampleQuestion Pinned versionv1.1.0What version was this designed against? Environment aliasenv/prodWhat is currently deployed? Integration aliasmainWhat is the current accepted contract? There is no correct answer. Each strategy is a tradeoff. The following sections work through what each one gives you, what it costs, and what conditions it requires to work in your favor. Pinned references A pinned reference points to an immutable artifact. Even when the applications constantly evolve. A pinned v1.1.0 today is the same v1.1.0 tomorrow. Advantages: Reproducibility. The same reference always resolves to the same document. Auditability. You can see exactly what a client was designed against. Traceability. A breaking change is visible by diffing versions. The drawback is structural. The platform does not stand still. Topics get added. Schemas evolve. Bindings change. The moment a pinned reference is created, the platform begins to diverge from it. A provisioning pipeline reading that reference is acting on a snapshot that is already out of date. The gap widens with every deployment. Pinned references answer the design question precisely. But as a source of truth for the current state of the platform, they drift from the first day. Environment alias An environment alias points to whatever is currently deployed. The URL is stable; the content behind it changes when the provider ships a new version. The appeal is direct: the alias represents exactly what is running in production right now. If the goal is for the spec to represent the platform, this seems like the obvious choice. The problem emerges with cross-service features. Consider a feature that spans two services: the Orders service adds a new channel and the Payments service subscribes to it. It cannot be promoted to production simultaneously. The Payments service cannot complete its API-First definition until Orders is deployed to production. One service deploys first because the target $ref does not exist yet: authoring tools don’t work, linting fails, CI/CD pipelines can not resolve it. This is not an edge case. Every non-trivial feature in an event-driven system spans multiple services. The environment alias makes each of those deployments a coordination problem. Bootstrap is the degenerate version of the same issue. A brand-new provider has nothing deployed yet. The alias cannot resolve. But the client needs to reference the spec before either service can ship. Circular dependency from day one. Environment aliases answer the operational question. They introduce coordination complexity that grows with every cross-service feature. Moving integration alias An integration alias points to the latest accepted state: the tip of the provider’s main branch. In a typical Git flow, a provider team opens a feature branch to add a new channel. They merge it to develop and provision the development environment: the new Kafka topic is created, the schema is registered, the application is deployed. They test. When the testing passes, they open a pull request to main. The PR is the governance event. On merge, a release tag is created. From there, the tag travels environment by environment, staging is provisioned and deployed, then production. Each environment reflects the spec at the moment the tag was created. main is the point where a change has been accepted and is ready to be promoted. Not yet in production, but past the gate. The name is stable. The content moves as the provider evolves. The movement is audited through Git: every change is a commit with an author, a timestamp, and a diff. This is the critical distinction from the environment alias. main does not represent what is in production right now. It represents what has been accepted as the current contract: the changes that have passed review, been merged, and are ready to be promoted. A channel can exist in main before it is deployed. A deprecated channel can remain in production but already unavailable in main until an deployment removes it from production. main is not the current state of the platform. It is the desired state: the contract that will be promoted environment by environment until it reaches production. This model works well for infrastructure-as-code. You describe what you want. The pipeline converges toward it. main does not answer every question, though. ⚠️It does not tell you what is currently provisioned in the broker. ⚠️It does not tell you whether a topic is live or whether you are safe to deploy. Those are runtime questions that belong to a different layer of validation, not to the contract. main is also ⚠️a moving target. If a channel identifier is renamed, every client referencing the old name immediately points to something that no longer exists. The breakage is silent and instant. What main does give you is ✔️parallel delivery. The moment a new channel is merged to develop, any service that needs it can start developing against it: authoring, linting, CI, all of it resolves. The moment the provider promotes to main, other services can promote their own changes to main as well and begin their own environment-by-environment journey independently. No coordination toll. Services do not wait for each other and the contract is decoupled from the deployment. But to make this advantage work, some discipline is required. The following invariants are not optional. Required invariants The integration alias only works if the main branch is kept coherent. That requires accepting a set of invariants that are not optional. Channel identifiers must be stable. A rename looks like a deletion followed by a creation. Any client referencing the old identifier silently points to a channel that no longer exists. New channels get new identifiers. Existing identifiers do not change. You can enforce this naming convention with standard linting tools. Backwards compatibility is maintained until consumers migrate. A channel cannot be removed from main until every client that references it has been updated. Removing it first breaks every client’s CI and provisioning pipeline immediately. Breaking changes are explicit and governed. A schema change that breaks existing consumers must be recognized as such before merging. Even better do not allow breaking changes on a live topic. User compatibility rules and version the topic if required changes are not compatible with existing clients. Liveness is validated separately. The spec says a channel should exist. Whether it does exist in the broker is a separate check. Do not mix them. If you are deploying or provisioning a client that depends on a channel that does not exist in an environment, just fail the CI/CD pipeline. These invariants are the price of the strategy. They are not optional mitigations. Without them, main is not a stable reference. But with them it offers a stable reference clients can use and develop in paralell. Conclusion There is no universal reference strategy. Each one answers a different question, and each one is blind to something. StrategyAnswersDoes not answerParallel development Pinned version (v1.1.0)What this was designed againstCurrent platform or application stateNo Environment alias (env/prod)What is deployed in that environmentFeatures not yet promotedNo Integration alias (main)What has been accepted as the contractDesign intent or deployment stateYes, with discipline Pinned references answer the design question precisely. The problem is drift, and it comes from two directions at once: the platform evolves past the pin, and the application that referenced it has moved on as well. The spec and the system diverge from day one. Environment aliases answer the deployment question precisely. The problem is that they make parallel feature development impossible. In an event-driven or distributed architecture of any size, features routinely span multiple services. Blocking cross-service development until one service is deployed to production is not a minor inconvenience. It is a structural constraint that limits how your teams can work. Integration aliases answer neither of the above questions. They do not tell you what was designed or what is deployed. What they give you instead is a stable reference name that lets teams develop and deploy in parallel. The moment a contract change is accepted into main, every dependent team can move forward independently, without waiting for production to catch up. That apparent weakness is the actual advantage. main represents a future that has been committed to, not a present that can be observed. With discipline on the required invariants, that is enough. The next post compares three technologies that can host the provider spec — Git, Apicurio Registry, and Artifactory — and examines how well each one supports these strategies in practice. Originally published at ivangsa.com.

AsyncAPI: Your Application Has Two API Surfaces

AsyncAPI: Your Application Has Two API Surfaces The AsyncAPI spec says an application SHOULD describe its operations, but not necessarily all of them in the same spec file. Not all operations are equal: some define the public API surface, what the application offers; others describe internal dependencies, what it needs. It makes sense to model them separately. The AsyncAPI v3 specification says: The AsyncAPI document SHOULD describe the operations an application performs. That “SHOULD” opens the door. Not all operations belong in the same file. Before the solution, a few concepts need to be clear. Events and commands Regarding the intention of asyncronous messages, we can differentiate Events from Commands and Responses. Events are facts. Something happened. OrderCreated. PaymentAuthorized. They are named in the past tense, owned by the application that produced them, and broadcast for anyone who cares to react. Commands are requests. Do something. ValidateDocument. SendNotification. They are addressed to a specific application, which may accept or reject them. Sometimes they require an specific directed Response message. Provider and client roles Regarding the role an application plays, we can differentiate the provider of a feature from its clients. A provider is the application that owns a specific capability. Ownership shows up in two ways: it is the authoritative source of some data being published, or it is the one that performs requested actions on behalf of others. The Orders service owns the order lifecycle: OrderCreated is its data to publish because no other application has the right to declare that an order was created. When another service sends a CancelOrder command, it goes to Orders because Orders is the only application that can carry out that action. That is why it is the provider for that topic. A client is any application that uses a capability it does not own. The same Orders service subscribes to PaymentAuthorized from Payments and sends a ReserveStock command to Inventory. For those topics, Orders has no authority. It is consuming data and requesting actions that belong to someone else. Provider and client are topic-level roles, or more precisely operation-level roles. The same application is a provider for the topics it owns and a client for everything else it depends on. Provider and client differ from producer and consumer depending on the intention of a specific message, whether it is a domain event or an async command. A provider consumes async commands and produces domain events and command responses. A client on the other hand produces command requests and consumes domain events and command responses. That distinction is what makes “provider” and “client” more useful than “producer” and “consumer” for modeling with AsyncAPI. One note on scope: this distinction applies to business domain events and command channels where a single application is the clear authority. Some technical notification patterns have multiple producers and multiple consumers and do not fit the provider/client frame cleanly. Model those accordingly. The pattern here is for business domain semantics. Broker mediated APIs have mirror symmetry In REST APIs, client and server roles are obvious. A client sends a request. A server handles it. The contract belongs to the server. No ambiguity about who owns what. Broker-mediated APIs are different. The same channel can be seen as a publication or a subscription, depending on which side you observe from. The broker sits in between, and technically both producer and consumer are clients of the broker, not of each other. This mirror symmetry is a common source of confusion. How do you define an async API? From which point of view? Do you need two separate specs for the same message? There are a few strategies, each with tradeoffs: Two independent complete specs. One per side. No ambiguity about point of view, but every channel definition is duplicated. Two sources of truth for the same thing. One neutral spec for channel and message only. No duplication, but you lose operation metadata: who publishes, who consumes, group IDs, principal names. One spec from the provider’s point of view, with clients inferring the inverse. The provider publishes a single, self-contained spec with no external $refs. This is the public API surface. Clients read it and know what to do on their side. Two specs, one per side, with the client referencing the provider’s spec via $ref. Clean ownership, no duplication, but requires canonical URLs and tooling that can resolve external references across repos. The last option offers the best tradeoff. It avoids naive duplication and preserves information about who publishes and consumes. Most importantly, it keeps the provider spec self-contained and free of external $refs. The provider spec is the public contract. Keep it self-contained and free of external $refs. Clients reference it; it references nothing. A provider spec with no external references is simpler to publish, simpler to validate, and simpler to consume. That is the recommended starting point. Two surfaces, two files If you put all of this in a single AsyncAPI document, the boundary disappears. You end up with a flat list of channels and operations where readers must infer which contracts this application owns and which belong to others. That inference gets harder as the system grows. The cleaner model is two files. asyncapi.yml describes what this application provides: the events it publishes, the commands it accepts, the channels it owns. This is the public contract. Other teams depend on it. It belongs in your schema registry. When it changes, that change is deliberate and goes through whatever governance process your organization runs for breaking changes. asyncapi-client.yml describes what this application consumes: the channels it subscribes to, the messages it reads, the commands it sends outward. This is the internal view. It is useful for the team building and operating the application, but it carries no contract obligations to anyone else. The names can vary. asyncapi-public.yml and asyncapi-internal.yml. asyncapi-provider.yml and asyncapi-consumer.yml. Pick what reads well for your team. What matters is the separation, not the label. What they look like The Orders provider spec is self-contained. One domain event. No external references. Everything any consumer needs is in this file. # asyncapi.yml (Orders service, provider) asyncapi: 3.1.0 info: title: Orders Service version: 1.0.0 channels: orderCreated: address: orders.events.order-created messages: OrderCreated: $ref: '#/components/messages/OrderCreated' operations: publishOrderCreated: action: send channel: $ref: '#/channels/orderCreated' components: messages: OrderCreated: payload: type: object properties: orderId: { type: string } customerId: { type: string } createdAt: { type: string, format: date-time } The Payments service consumes that event. Its client file does not redefine the channel. It references the entire channel from the Orders provider spec: address, messages, bindings, all of it. # asyncapi-client.yml (Payments service, client of Orders) asyncapi: 3.1.0 info: title: Payments Service (Client) version: 1.0.0 channels: orderCreated: $ref: '<orders-service-asyncapi-url>#/channels/orderCreated' operations: onOrderCreated: action: receive channel: $ref: '#/channels/orderCreated' The only thing the client adds is the receive operation. The channel definition, including the topic address and the message schema, belongs to Orders, and the client points to it. The placeholder <orders-service-asyncapi-url> is where the decision lives. Reference, but what exactly? The client and the provider are maintained by different teams and evolve at their own pace. The client references the provider spec, but what exactly does it point to? There are three distinct strategies, and each one answers a different question. StrategyQuestion Pinned version (v1.1.0)What version was this designed against? Production alias (prod)What is currently deployed? Integration alias (main)What is the current accepted contract? These are not interchangeable. Switching strategies does not improve the answer. It changes the question being asked. Pin to a version and drift is invisible: the client accumulates stale assumptions without noticing. If you track prod you can not perform coordinated parallel deployments, client service alway have to wait for provider spec to be in production. If you trac an integration alias like main you can more easily perform parallel deployments but you loose track of which environment it is already available, if at all. This is no easy question. The next post works through each strategy, its constraints, and the tradeoffs, and arrives at a concrete recommendation. Conclusion A service has two perspectives: what it offers what it needs Modeling them separately makes the boundary between both explicit. Originally published at ivangsa.com.

Domain Modeling with ZDL: Aggregates, Commands, Events, and State Machines

Domain Modeling with ZDL: Aggregates, Commands, Events, and State Machines ZDL is a compressed blueprint of the business: aggregates, commands, events, and lifecycles captured in one readable model. The point is not that generation replaces design, but the opposite: because the design is written down as ubiquitous language, generation can preserve it across contracts, code, documentation, and tests. If you have been following the series, now we have generated the scaffold for all five service repos. One per bounded context. The AI read the ZFL, understood the business context, and produced a grounded starting point for each one. This was just a starting point. Now it is our job to design each bounded context with its entities, aggregates, commands and events. We’ll be starting with the Orders Checkout bounded context because every other service in this flow reacts to the orders it creates, confirms, or cancels. ZenWave Domain Language: all the DDD building blocks without boilerplate ZDL is a compressed blueprint of the functionality. It contains all the building blocks of DDD. Aggregates, entities, value objects, commands, events, state machines. Everything that expresses the business. Its main job is to help experts think clearly. ZDL gives us a compact and unambiguous way to talk about the model, validate it with business experts, and keep that mental model visible as the software design moves downstream to developers. Then, because the language is machine friendly, we can leverage ZenWave SDK to generate the boring parts that are already expressed in the model. APIs, events, domain models, tests. The same names and rules can flow through OpenAPI, AsyncAPI, backend code, documentation, and tests without the ubiquitous language slowly drifting in each artifact. Modeling the core of the business We are not modeling tables or endpoints first. We are modeling a business domain. This is the core of the Arcadia business. The part that is new, that has no off-the-shelf answer, that we are still discovering. CRUD can be the right tool for generic or supporting domains, and ZDL handles that well, but here the important thing is behavior. Design Level Event Storming gives us the language and the mental model from the people who understand the business. ZDL is how we write that model down in a form that is still readable by humans, but precise enough for tools. That shortens the feedback loop between business experts and developers while keeping the language coherent from discovery to implementation. In this domain the sequence matters. An order moves through states. Some transitions are valid. Some are not. That is not a technical detail. That is the business rule. State machines are paramount because entities and aggregates always have a lifecycle. Even in the most basic CRUD application, records are created, updated, archived, deleted, approved, rejected, enabled or disabled. There is always some progression, even if we do not make it explicit. When that lifecycle matters to the business, we should model it directly. A state machine gives that lifecycle a clear shape: the valid states, the valid transitions, and the operations that move the entity from one state to another. The Order aggregate We start with the aggregate. In ZDL an aggregate is the consistency boundary. The thing that enforces the rules. Everything that must always be consistent lives inside it. For Orders Checkout that is the Order itself. It owns the order lifecycle. It does not own payment processing or catalog inventory. It starts the checkout and then reacts to business facts from the other bounded contexts. @aggregate @lifecycle(field: status, initial: CREATED) entity Order { orderId String required unique status OrderStatus required items String[] required createdAt Instant confirmedAt Instant cancelledAt Instant } enum OrderStatus { CREATED CONFIRMED CANCELLED } The @lifecycle annotation names the field that carries the state and the initial value. From this moment, the Order is not a row in a table. It is a business object with a defined lifecycle. Note: ZDL supports two styles of aggregate modeling: The data-centric style shown here keeps commands and events at the service level. ZDL also supports a behavior-centric style where the aggregate itself models its own commands and events, closer to what DDD purists would call a rich domain model. That style makes the most sense when a service coordinates between two or more aggregates. We are not covering it in this tutorial but it is there when you need it. Commands as transitions Each meaningful business operation is a named command. Commands are grouped in Services like this service OrdersCheckoutService for (Order). ZDL decorators document how each command enters the system, whether through a REST operation or an incoming event, and give ZenWave SDK enough information to generate draft contracts when we own those entry points. input StartOrderCheckoutInput { items String[] minlength(1) } input ConfirmOrderInput { orderId String required } input CancelOrderInput { orderId String required } @rest("/orders") service OrdersCheckoutService for (Order) { @post @transition(to: CREATED) startOrderCheckout(StartOrderCheckoutInput) Order withEvents [OrderCreated | StockUnavailable] @asyncapi(api: PaymentsProcessingApi, channel: PaymentAuthorizedChannel) @transition(from: CREATED, to: CONFIRMED) confirmOrder(ConfirmOrderInput) Order withEvents OrderConfirmed @asyncapi(api: CatalogInventoryApi, channel: StockReleasedChannel) @transition(from: [CREATED, CONFIRMED], to: CANCELLED) cancelOrder(CancelOrderInput) Order withEvents OrderCancelled } The @transition annotations are the first explicit documentation of the state machine. They make the lifecycle visible in a way that domain experts and technical experts can discuss together. An order can only be confirmed if it is in CREATED state. It can be cancelled from CREATED or CONFIRMED, but not once it is already CANCELLED. There is no command that moves an order backward. Later, when we generate or implement the service, those same transitions become guards in the code. They prevent a command from being executed when the aggregate is in an invalid state. The rule is not hidden in an if statement that we have to rediscover later. It is part of the model from the beginning. Notice also that startOrderCheckout is a REST command initiated by an actor. confirmOrder arrives after payment has been authorized, and cancelOrder arrives when stock has been released. Same command concept, different transport. The model expresses both. Modeling Domain Events Commands express intent. Events express facts. startOrderCheckout, confirmOrder, and cancelOrder are things we ask the Orders Checkout service to do. OrderCreated, StockUnavailable, OrderConfirmed, and OrderCancelled are things that already happened in the business. That past-tense language matters. An event is not a request for another service to do something. It is a fact published by the bounded context that owns the aggregate. Events carry the information about relevant changes inside a bounded context. They are meant to be published to the outside world, so they eventually need to be documented through an API-first specification like AsyncAPI. In ZDL, events are a compact IDL for that contract. AsyncAPI becomes the reviewed external contract for outside communication, but writing the event first in ZDL gives us a concise representation that ZenWave SDK can use to generate the draft AsyncAPI definition. The withEvents clause connects a command with the domain events it can emit. Then we model those events explicitly: @asyncapi({ channel: "OrderCreatedChannel", topic: "orders.events.order-created" }) event OrderCreated { orderId String version Integer } @asyncapi({ channel: "StockUnavailableChannel", topic: "orders.events.stock-unavailable" }) event StockUnavailable { productId String requestedQuantity Integer } @asyncapi({ channel: "OrderConfirmedChannel", topic: "orders.events.order-confirmed" }) event OrderConfirmed { orderId String version Integer confirmedAt Instant } @asyncapi({ channel: "OrderCancelledChannel", topic: "orders.events.order-cancelled" }) event OrderCancelled { orderId String version Integer cancelledAt Instant } These events are part of the public language of the bounded context. Other services do not need to know how Orders stores its data or implements its workflow. They react to the facts Orders publishes. This is also where the model becomes an event contract. The @asyncapi decorators describe how each event leaves the system: the channel, the topic, and the payload shape. From a compact event definition, ZenWave SDK can generate the corresponding AsyncAPI schema, message, channel, and send operation. For example, OrderCreated becomes an AsyncAPI schema with the orderId and version fields. It also becomes a message pointing to that schema, a channel named OrderCreatedChannel, and a send operation for publishing that message to the configured topic. There is one important detail: only emitted events are included in the generated AsyncAPI definition. Defining an event is not enough. The event must be referenced by a service command with withEvents, because that is what tells the model this service actually publishes it. The important part is that the event contract is not invented later by a developer while wiring Kafka. It comes from the same model that names the commands, the aggregate, and the state machine. The transition changes the aggregate state, and the emitted event tells the rest of the system what business fact just became true. ZenWave SDK Backend Plugin can generate the code that publishes those events as part of the service commands. The event data structures themselves are generated from the AsyncAPI side by the ZenWave AsyncAPI plugins. That separation is useful: ZDL gives us the compact domain model, AsyncAPI gives us the external contract, and the generators keep both aligned. From the model to backend building blocks This is where ZenWave SDK starts to pay off in a very practical way. Once the domain model is explicit, we can use the growing list of ZenWave SDK plugins to generate many of the building blocks of a Spring Boot backend application, in Java or Kotlin. Not the business decisions. Those still belong to us. But the repetitive structure around those decisions can come from the model. The ZDL to OpenAPI plugin can turn REST-facing services and DTOs into an OpenAPI definition. The ZDL to AsyncAPI plugin can do the same for emitted events and async operations. From there, the API-first plugins can generate the adapters around those contracts. For example, the OpenAPI Controllers plugin can generate Spring MVC controller implementations, mappings and tests from the OpenAPI contract and the ZDL model. The Backend Application Default plugin can generate the backend core: entities, repositories, service interfaces, service implementations, mappers, package structure and event publishing hooks, following the selected project layout. The important point is not that generation replaces design. It is the opposite. Because the design is captured in ZDL, generation can preserve it across the application. The aggregate, commands, transitions, events, APIs, controllers, persistence and tests all start from the same language. That gives us a much faster feedback loop. We can change the model, regenerate the boring parts, and focus our attention on the parts that actually require judgment: the business behavior, the edge cases, and the conversations with domain experts. Originally published at ivangsa.com.

From ZFL to ZDL: AI-Assisted Domain Model Scaffolding

From ZFL to ZDL: AI-Assisted Domain Model Scaffolding ZFL describes a business flow with commands, events, services, and sometimes aggregate names. An AI agent can turn that into scaffolding for your service repositories. We have already translated EventStorming, a process-modeling technique, into a structured language: ZFL Flow Language. It describes how commands and events move across services as part of a business flow. We want to treat each service API and its models as products in their own right, with their own repositories, linting, validation, and publishing pipelines. An AI agent can read the ZFL and turn it into an initial scaffold for each of those services. To do that reliably, we created a reusable agent skill. Because we are starting Arcadia Editions from scratch, we can scaffold all those services in one shot. If we were working inside an established company, we could still ask the agent to update our domain models with newly discovered commands and events. AI scaffolding helps us get started. It can take the ZFL and produce the first folders, files, and domain-model.zdl documents so we are no longer staring at a blank page. But ZenWave Platform is the tool that helps us do the real architectural work around that scaffold. It helps us design, analyze, navigate, and investigate the system as a whole. Instead of looking at isolated repositories or disconnected contracts, we can understand the big picture of the entire company architecture: bounded contexts, business flows, APIs, events, schemas, and generated services, all connected as one navigable model. That is the difference. AI gives us a starting structure. ZenWave Platform helps us understand what we are building, how the parts relate to each other, and where to go next. From one ZFL to multiple Service Repos From the user point of view, the result is simple: from one ZFL flow, the agent generates one service scaffold per system. For each service, you get an initial domain-model.zdl with the main pieces already in place: A bounded context model for that service. An aggregate candidate when the flow gives enough signal. A service definition in ZDL. One command per command the service handles in the flow. The events that service emits. The first lifecycle states and transitions when they can be inferred from the flow. That means the flow is turned into service-level structure. If a command starts from an actor, you get an actor-facing command in the service model. If a command is triggered by another event, you get an event-driven command in the service model. If one service calls another synchronously, you get a command in the called service and the orchestration stays in the caller. So the output is not one big model. It is several smaller starting points, one per service, each grounded in its part of the business flow. What you get is scaffolding, not a finished design. You still decide: whether the aggregate is really the right one which fields matter where the boundaries are which transitions are valid which events are worth publishing That is the practical value of the generation step: it turns one business flow into multiple service repos with usable starting models, so you can move directly into refinement instead of setup. The AI skill An AI skill is a reusable instruction pack for an agent. It gives the agent the rules, references, and examples it needs before doing a task. For this task, the zfl to zdl skill gives the agent three things: The grammar of valid ZDL. A working example to follow for structure and naming. The mapping rules from ZFL flow elements into service-level scaffolds. The ZFL provides the business context: systems, services, commands, events, and sometimes aggregate hints. The skill provides the constraints. Together, they let the agent generate a valid starting point without inventing its own format. The instruction The instruction itself was short: Create one service scaffold per system in the ZFL. Generate an initial domain-model.zdl for each one. Include the commands, events, and aggregate candidate when the flow gives enough signal. Keep the result as scaffolding, not a finished model. That last part matters. The goal is not to fake a complete design. The goal is to create a usable starting point. Here is the service block from the Orders scaffold. The three commands show exactly how the ZFL mapping works. @aggregate @lifecycle(field: status, initial: CREATED) entity Order { status OrderStatus required // ... items OrderItems[] required } enum OrderStatus { CREATED, CONFIRMED, CANCELLED } @rest("/orders") service OrdersCheckoutService for (Order) { @post @transition(to: CREATED) startOrderCheckout(StartOrderCheckoutInput) Order withEvents [OrderCreated | StockUnavailable] @asyncapi(api: PaymentsProcessingApi, channel: PaymentAuthorizedChannel) @transition(from: CREATED, to: CONFIRMED) confirmOrder(ConfirmOrderInput) Order withEvents OrderConfirmed @asyncapi(api: CatalogInventoryApi, channel: StockReleasedChannel) @transition(from: [CREATED, CONFIRMED], to: CANCELLED) cancelOrder(CancelOrderInput) Order withEvents OrderCancelled } The full file with the entity, lifecycle enum, events, and plugin config is in the orders-checkout-api repo. The same scaffolding was generated for the other services that participate in the PlaceOrder flow: catalog-inventory-api, payments-processing-api, fulfillment-shipping-api, and notifications-consumer-api. Product Catalog has its own repo too, catalog-products-api, but it is not part of this specific flow scaffold. You can learn more about ZFL Flow Language and how to map lightweight and rich domain aggregates in the official docs. Why AI and not a deterministic generator ZenWave SDK already has deterministic generators. From a ZDL model it can generate AsyncAPI specs, OpenAPI specs, Spring Boot backends, and documentation. Given the same input, those generators produce the same output every time. ZFL to ZDL is different. The mapping is not purely mechanical. A business flow does not fully specify a domain model. It suggests service boundaries, commands, events, responsibilities, and sometimes aggregate hints, but it still leaves room for interpretation. That is where an AI agent helps: it can read the flow, follow the skill, and produce a conservative scaffold that is coherent enough to refine. The ZFL is the map of the process. The AI prepares the workbench. We still build the model. Where our work continues The scaffold is the beginning, not the end. Inside each service repo, the real domain modeling work starts: What does this bounded context actually own? Where are the aggregate boundaries? Which fields carry business meaning? Which events are worth publishing? What should be exposed through REST, and what belongs in AsyncAPI? The ZFL gave us the flow. The scaffold gave us the first structure. Now we go inside each service and discover the model. We start with Orders, the center of gravity of the PlaceOrder flow. Originally published at ivangsa.com.

Completing the ZFL: From Bounded Contexts to Systems

Completing the ZFL: From Bounded Contexts to Systems Event Storming has two phases. First you discover the flow. Then you find the centers of gravity. The service field in ZFL is where that second phase becomes explicit, and where you start building the architectural world model. In the previous post we found the main centers of gravity inside the PlaceOrder flow. Catalog Inventory, Orders Checkout, Payments Processing, Fulfillment Shipping, and Notifications Consumer. We found them by looking for centers of gravity. Business objects that receive commands, enforce rules, own state, and emit events. Each center of gravity became a bounded context. But we left the ZFL incomplete. If you go back to the flow we wrote, every command block has a service field that is empty. We skipped it deliberately. At that point we did not know who owned what. Now we do. And filling in those fields is not just housekeeping. Why this matters A ZFL flow with no service fields tells you what happens. It does not tell you who is responsible. That distinction matters for two reasons. The first is practical. In the next post we are going to feed this ZFL to an AI agent and ask it to generate the ZDL domain model skeletons, one per bounded context. Without the service fields, the agent has no way to know which commands and events belong to which context. The mapping is implicit in our heads. It needs to be explicit in the file. The second reason is bigger. Every field we fill in here is a link in the architectural world model. A navigable connection between the flow and the domain model, between the command and the service that handles it, between the service and the aggregate that owns the state. The more explicit we are, the richer the graph. Architects can follow those links. Tools can follow those links. AI agents can follow those links. We are not filling in fields. We are building the index of the architecture. Two phases, one ZFL Event Storming works in two phases. The first phase is discovering the flow. You put events on the board, connect them with commands, link everything with policies. You are telling the story of the business. No boundaries yet. Just sequence and causality. The second phase is finding the centers of gravity. You step back and look at the board. Which commands and events cluster around the same business object? You draw circles. Each circle is a candidate bounded context. The ZFL mirrors this exactly. You write the flow first with no service fields. That is phase one. Then once you have found your bounded contexts, you go back to each command block and fill in who owns that command. That is phase two. The service field is where the second phase of Event Storming becomes explicit in the ZFL. The service field: how explicit can you be? The service reference accepts different levels of precision: System: CatalogInventory. System.Service: CatalogInventory.InventoryService. System.Service.Aggregate: CatalogInventory.InventoryService.StockReservation The more precise you are, the more the platform can do with it. At the system level you know which bounded context owns the command. At the service level you know which service handles it. At the aggregate level you know which business object owns the state, and that link travels all the way to the ZDL model, the AsyncAPI spec, and the generated code. You can use either . or / as separator, so CatalogInventory.InventoryService.StockReservation is equivalent to CatalogInventory/InventoryService/StockReservation For this flow example we mostly use service level references. For inventory, we also name the aggregate just as an example, so you know how to explicit that level of precision. NOTE: when you are using ZenWave Platform IntelliJ plugin, the systems block can be generated for you by reading the service fields. Filling in the service fields Walk the flow. For each command block, ask: which system/service owns this command? The answer comes directly from the previous post. We already did the thinking. Now we are just writing it down. StartOrderCheckout triggers startOrderCheckout. The checkout request is owned by Orders Checkout. That is OrdersCheckout.OrdersCheckoutService. Inside that step, Orders Checkout calls reserveStock. Stock reservation is owned by Catalog Inventory. That is CatalogInventory.InventoryService. When stock is reserved, Orders Checkout emits OrderCreated. That event triggers authorizePayment. Payment authorization is owned by Payments Processing. That is PaymentsProcessing.PaymentsProcessingService. If authorization fails for a technical reason, PaymentFailed triggers retryPayment. The retry is still owned by Payments Processing. A successful retry loops back into authorizePayment. A hard decline or exhausted retry path moves toward cancellation. PaymentAuthorized triggers confirmOrder. Order confirmation is owned by Orders Checkout. Back to OrdersCheckout.OrdersCheckoutService. OrderConfirmed triggers scheduleFulfillment. Fulfillment is owned by Fulfillment Shipping. That is FulfillmentShipping.FulfillmentShippingService. FulfillmentScheduled triggers capturePayment. Money moves only when the order is physically ready to leave the warehouse. Payment capture is owned by Payments Processing. PaymentCaptured triggers sendOrderConfirmation. Notifications are owned by Notifications Consumer. That is NotificationsConsumer.NotificationsConsumerService. The failure paths follow the same logic. StockUnavailable triggers a stock unavailable notification. FulfillmentFailed and PaymentCaptureFailed trigger voidPayment. PaymentDeclined, PaymentRetryExhausted, PaymentVoided, and ReservationExpired trigger releaseStock. StockReleased triggers cancelOrder, and OrderCancelled triggers a cancellation notification. Each block now has a complete picture. Trigger on the left. Command in the middle. Service named. Outcome on the right. The systems block emerges Once every command block has a service field, the systems block writes itself. It is the set of unique services you referenced, with a pointer to where their domain model will live. There is a subtle but important limit here. The systems block is derived from the services referenced by this flow. It is not a complete enterprise map. Product Catalog, for example, is a real Arcadia service. It owns product descriptions, prices, edition metadata, launch dates, and tracking mode. But PlaceOrderFlow does not call it. Checkout starts with SKUs, and the flow only needs Catalog Inventory to reserve or release scarce stock. So Product Catalog belongs in the broader architecture manifest, but not necessarily in this flow specific systems block. ZFL should show the participants in the business process being modeled, not every service the company owns. systems { @zdl("catalog-inventory-api/domain-model.zdl") CatalogInventory { service InventoryService for (StockReservation) { commands: reserveStock, releaseStock } } @zdl("orders-checkout-api/domain-model.zdl") OrdersCheckout { service OrdersCheckoutService { commands: startOrderCheckout, confirmOrder, cancelOrder } } @zdl("payments-processing-api/domain-model.zdl") PaymentsProcessing { service PaymentsProcessingService { commands: authorizePayment, retryPayment, capturePayment, voidPayment } } @zdl("fulfillment-shipping-api/domain-model.zdl") FulfillmentShipping { service FulfillmentShippingService { commands: scheduleFulfillment } } @zdl("notifications-consumer-api/domain-model.zdl") NotificationsConsumer { service NotificationsConsumerService { commands: sendOrderConfirmation, sendStockUnavailableNotification, sendOrderCancelledNotification } } } The @zdl annotation points to the domain model file for each system. That pointer is a navigation edge. From the flow you can jump to the model. From the model you can jump back to every command and event in the flow that touches it. You can build this block by hand by collecting the services you referenced. But you do not have to keep it in sync manually forever. The ZenWave Platform Plugin for IntelliJ IDEA already includes an action for this: Code > Organize ZFL Services. It reads the service fields used in the flow, organizes the systems block, and creates any missing pieces it can infer. That keeps the map of services aligned with the command ownership written in the when and do blocks. One small caveat. The platform is evolving as we build this in public, so the exact menu names may change over time. The idea is the important part: the systems block should be derived from the service references in the flow, not maintained as a disconnected list. The relevant parts We do not need to paste the whole flow here. The complete file is available in place-order-flow.zfl. The important change is visible in the command blocks. Each command now says who owns it. when StartOrderCheckout do startOrderCheckout { service OrdersCheckout.OrdersCheckoutService call reserveStock on StockReserved emits OrderCreated on StockUnavailable emits StockUnavailable } do reserveStock { service CatalogInventory.InventoryService response StockReserved response StockUnavailable } The same pattern appears in the event driven part of the flow. when OrderCreated do authorizePayment { service PaymentsProcessing.PaymentsProcessingService emits PaymentAuthorized emits PaymentDeclined emits PaymentFailed } when OrderConfirmed do scheduleFulfillment { service FulfillmentShipping.FulfillmentShippingService emits FulfillmentScheduled emits FulfillmentFailed } And the timer now has an owner too. @actor(Scheduler) @time("10 mins after OrderCreated and not PaymentAuthorized or PaymentDeclined or PaymentRetryExhausted") start ReservationExpired { orderId String } What Comes Next That is the document that the next post takes as input. The ZFL already has boundaries, services, commands, events, and even aggregate names when we choose to model at that level. The systems block is the glue. Without it, an AI agent has no way to know which commands belong to which context. With it, the mapping is explicit enough to create the scaffolding around the model. That does not mean the agent designs the domain for us. We still model by hand. We still decide what the aggregate really owns, what fields carry meaning, what transitions are valid, and which events deserve to exist. What the agent can do is give us a head start. It can create the folders, files, service skeletons, initial ZDL documents, and the boring structure we need before the real modeling begins. That is where we go next. From a completed flow to the first service scaffolds. Originally published at ivangsa.com.

From Events to Bounded Contexts: Finding Arcadia Editions' Architecture

From Events to Bounded Contexts: Finding Arcadia Editions' Architecture Large systems become unmanageable when everyone shares the same model. This post walks through the heuristic we used to find those boundaries in Arcadia Editions, looking for the business objects that act as centers of gravity for commands and events, and using the consistency requirement to draw the line around each one After the Event Storming session, we had a wall full of events. StockReserved. OrderCreated. PaymentAuthorized. FulfillmentScheduled. A timeline that told the story of a customer buying a limited edition game during a new release. Non technical, from the business point of view, in the business language, grounded in the business. Event Storming gives you an story-line, it brings up conversation arround hotspots and also helps discover boundaries. Now is time to identify those boundaries that make the business language self contained. With boundary identification we are entering in the Solution Space. See Problem Space vs Solution Space. This post is about one heuristic that worked for us. This is not a formal algorithm but one question, applied consistently. The question Which business objects that act as centers of gravity for commands and events? That is an aggregate, a center of gravity, a business object that receives commands, owns state, enforces rules, changes over time, and emits domain events as a result. If something behaves like that, it is a candidate model. And very likely a candidate for a system boundary. The aggregate is the thing that enforces consistency. And aggregates are the centers of gravity around which bounded contexts form. We still need to decide which aggregates to group arround a bounded context, but their gravity and interactions are the best clue for discovering bounded contexts. Applying it to the Place Order flow Walk the flow. At each step, ask: what is the business object at the center of this cluster of commands and events? Catalog Inventory. Something receives reserveStock and releaseStock, enforces scarcity, and emits StockReserved, StockUnavailable, StockReleased. It owns whether a checkout can claim stock, which reservation holds it, and when that stock returns to the pool. The center of gravity is not the product description. It is the reservation of scarce inventory. That becomes Catalog Inventory. Orders Checkout. Something receives startOrderCheckout, confirmOrder, cancelOrder. It owns the commercial commitment and the order lifecycle. It does not own payment or stock. It reacts to them through events. OrderConfirmed is the pivotal event. To the left of it, the language is commercial. To the right of it, the language becomes operational. Picking, packing, shipping. That shift in meaning is the boundary signal. This is a center of gravity. It becomes Orders Checkout. Payments Processing. Something receives authorizePayment, retryPayment, capturePayment, voidPayment, owns the payment lifecycle, integrates with external providers, and emits PaymentAuthorized, PaymentDeclined, PaymentFailed, PaymentCaptured, PaymentVoided as facts the rest of the system reacts to. Completely different rules, completely different lifecycle. This is a center of gravity. It becomes Payments Processing. Fulfillment Shipping. Something receives scheduleFulfillment. It decides whether fulfillment can be arranged and emits FulfillmentScheduled or FulfillmentFailed. Its language is entirely different from the commercial language of Orders. A different world with its own rules. This is a center of gravity. It becomes Fulfillment Shipping. Notifications Consumer. Something reacts to events from every other context and decides what to communicate, how, and through which channel. It does not own any core business state. Three different outcomes, three different messages, one customer on the other end. This is a center of gravity. It becomes Notifications Consumer. Customer. The customer is still important, but in this flow it appears as an actor, not as a bounded context. The customer starts StartOrderCheckout. The flow does not show customer profile rules, identity rules, or loyalty rules. So from this flow alone, Customer and Identity is not a context we can claim yet. Product Catalog. Product Catalog clearly exists in Arcadia Editions. Someone has to define the SKU, name, price, edition size, artwork, launch date, and whether an edition is quantity tracked or serialized. But in this flow, Product Catalog does not receive a command. StartOrderCheckout already carries the SKUs the customer wants to buy. The flow needs to know whether stock can be reserved, not how the product was created or merchandised. That is an important distinction. A flow discovers the services needed by that flow. It does not prove that no other services exist. So Product Catalog remains part of the broader Arcadia architecture, but it is outside the Place Order flow. Catalog Inventory is inside this flow because it receives reserveStock and releaseStock, owns reservation state, and emits stock events. Not every concept becomes a bounded context During Event Storming you will see many concepts that look important but are not centers of gravity on their own. OrderLine, Address, PaymentMethod. These do not receive independent commands. They do not have their own lifecycle. They do not emit meaningful events on their own. They belong inside a model, not as a model. StockReservation is different in this flow. It is the thing created, held, expired, and released by the inventory rules. That makes it a good aggregate candidate inside Catalog Inventory, even if the customer-facing product information still belongs to a product catalog model. The test is simple: can it change independently? Does it enforce its own rules? Does it emit its own facts? Mostly no means it belongs inside a center of gravity, not as one. Other signals worth knowing Centers of gravity is not the only way to find boundaries. There are others. Language shifts. When people in a real workshop start arguing about what an event means, or use different words for the same concept, that friction is a boundary. Two mental models colliding. The OrderConfirmed pivot above is a textbook example: same event, completely different meaning to Fulfillment Shipping, Payments Processing, and Notifications Consumer. Pivotal events. Some events change the kind of work the business is doing. Before OrderConfirmed, the flow is about intent, stock, and payment authorization. After OrderConfirmed, the flow becomes about fulfillment, capture, and communication. When an event changes the language around it, pay attention. It is often sitting on a boundary. Organizational boundaries. Conway’s Law works in both directions. If two teams have been arguing about who owns something for months, that ownership dispute is telling you something real about the domain. Rate of change. If two things always change together, they probably belong together. If they change independently, they probably do not. All of these are signals, not rules. They are useful when they confirm each other. When they contradict each other, you need to think harder. What this gives us Five candidate bounded contexts for this flow: Catalog Inventory, Orders Checkout, Payments Processing, Fulfillment Shipping, Notifications Consumer. Other contexts, such as Product Catalog or Customer and Identity, still exist in the wider business architecture. They are just not participants in this specific flow. But these are working hypotheses, not final answers. Bounded contexts belong to the solution space, the business solution we are discovering. They are fluid and may change as new information arrives. The next step is to make them explicit in the ZFL. That is where a boundary stops being a note on the side and becomes part of the flow. In the next post, Completing the ZFL: From Bounded Contexts to Systems, we will fill those service fields, add the systems block, and that will give as a differnt view, derived from this flow: the map of services or bonded contexts we will be implementing and conecting together through APIs. Originally published at ivangsa.com.