Model types
Published Language
A Published Language is the contract a bounded context offers the outside world. Not the model, and not the implementation: the messages and data shapes another team writes code against, stated precisely enough that both sides can generate from them.
DomoModeler holds one as a small registry. Schemas are filed by category, each schema has versions, and each version has a specification written in a typed language:
event OrderPlaced {
type schemaTypeName
version currentVersion
timestamp occurredOn
string orderId
data.Customer buyer
int quantity = 1
}
That specification is the source of truth. Everything else here reads it: validation, compatibility checking, source code in nine languages, syncing from a model.
Note: The Schema Registry is available on Pro plans and above. On a Guest or Starter organization the Published Languages section does not appear at all. If an organization drops off Pro, its existing languages stay listed and readable so that nobody's work disappears.
Where a Published Language lives
Expand a Domain card on the dashboard and you'll find a Published Languages section.
Where a language appears inside that card depends on what it describes:
- A language with exactly one source model is nested under that model, up in the models list, the way a Glossary sits under the Domain Model it came from.
- A language with no source, or with several, is listed in the Published Languages section itself. Nesting a language that spans every bounded context of a Context Map under any one of them would be a lie.
The section's own ⋯ menu chooses whether it sits above or below the models in the card. The current
choice is ticked.
Creating one
Click + in the section header, name it, and optionally pick a source model. Only a Context Map or a Domain Model can be a source; an EventStorming board or a Mind Map has no vocabulary to publish.
Naming a source is what files the language under that model rather than in the section, and it is what enables the Sync button in the editor later. You can add one afterwards.
The row menu
| Item | What it does |
|---|---|
| Edit | Opens the properties dialog: name, namespace, description. |
| Export | The encrypted archive of the whole language. Not built yet, shown disabled. Not to be confused with source-code export, which is in the editor and does work. |
| Delete | Deletes the language, its schemas and every version. Anything integrating against them stops resolving. |
There is deliberately no Share item. Access is inherited from the parent Domain, and distribution (source code today, third-party registries later) is what sharing a Published Language actually means in practice.
The hierarchy
Six levels, the first two of which you already know from the dashboard:
Product
Domain
Published Language "Orders"
Commands
PlaceOrder
1.0.0
1.1.0
Data
Customer
1.0.0
Events
OrderPlaced
1.0.0
2.0.0
OrderCancelled
1.0.0
Queries
FindOrder
1.0.0
A Product holds Domains; a Domain holds models and Published Languages. Both are covered in The dashboard. From the Published Language down, the three levels are the registry's own:
- Category: one of the six below. Categories with nothing in them are not shown.
- Schema: a named contract, such as
OrderPlaced. It is the thing that has an identity over time. - Version: a specification at a semantic version, with its own lifecycle status.
The left pane of the editor is this tree from the Published Language down. Click a level to expand or collapse it; click a version to open its specification.
The editor
Opening a Published Language gives you the standard Domo editor chrome: click the logo to return to the dashboard, click the title to rename, and presence and save status sit where they do in every other editor.
Four actions sit in the header toolbar:
| Action | Available when |
|---|---|
| Properties | You can edit. Name, namespace and description. |
| Sync | You can edit and the language has a source model. |
| Release Packages | Always. Naming a set of versions is covered above. |
| Export | Always. Generating files from what you can already read takes nothing away from anyone. |
The left pane shows the namespace, then the hierarchy described above. Selecting a version opens its specification for editing on the right.
Adding a schema and its versions
+ on a category adds a schema to it. A schema and its first version, 0.1.0, are created in one
step, because a schema with no version says nothing. Typing OrderPlaced under Events generates
event OrderPlaced { } for you to fill in rather than leaving an empty box.
The form also carries a description and a Scope of Public or Private.
Note: Scope is recorded but not yet enforced. Marking a schema Private states an intention; it does not currently hide the schema or leave it out of an export.
+ on an existing schema adds a new version. The version number is prefilled with a patch bump of the highest existing version. It is suggested, never imposed, since whether a change is breaking is your call and not a string's. The specification starts as a copy of the previous version's, retitled to the schema's current name and category: a new version is nearly always an edit of the last one, and starting from an empty box invites a copy-paste that quietly drops something.
Compatibility against that predecessor is checked as you type.
Schema categories
Six, and each maps to one keyword that opens a specification.
| Category | Keyword | What belongs in it |
|---|---|---|
| Commands | command |
A request for something to happen. |
| Events | event |
Something that has happened. |
| Queries | query |
An ask for information. |
| Data | data |
A shape referenced by other schemas rather than sent on its own. |
| Documents | document |
A whole document payload. |
| Envelopes | envelope |
Metadata wrapping a message. |
Documents and Envelopes are never produced by syncing from a model; they are messaging concerns with no
Domain Model equivalent. You can still author them by hand.
Writing a specification
The header writes itself
Type a name into the schema form and the specification is generated for you:
event OrderPlaced {
}
Change the name or the category afterwards and only the header is rewritten. The body is yours and is preserved exactly as typed, whitespace included. If the first token is something you wrote deliberately rather than a category keyword, it is left alone for you to fix.
Fields, written either way round
Both syntaxes mean exactly the same thing:
string orderId
orderId : string
The space around the colon is optional. Mixing them inside one specification parses, and a specification rewritten from one style into the other is not treated as a change; the comparison is on meaning, not on text, so it will not propose a new version or a sync update for it. That said, pick a lane.
Note: Field and schema names are letters and digits, starting with a letter. No underscores, and no hyphens. Use
orderId, notorder_id.
Special fields
Three keywords describe the message rather than its payload:
| Written | Means |
|---|---|
type schemaTypeName |
The fully-qualified schema name, carried in the message itself. Usually a string in the generated code. |
version currentVersion |
The semantic version of this schema version, carried in the message. |
timestamp occurredOn |
When the instance was created. Usually a long or a string. |
The name after the keyword is yours; the examples above are conventions, not requirements.
These three are excluded from compatibility checking, because they are envelope metadata rather than the contract being compared. Adding or removing one never counts as a breaking change.
Note: In the colon form you can name a payload field
type,versionortimestamp;type : stringdeclares an ordinary string field calledtype. The traditional form cannot express that, because there the word is always the special field.
Primitive types
| Type | Range or values |
|---|---|
boolean |
true or false |
byte |
0 to 255 |
char |
one UTF-8 character, in single quotes |
short |
−32,768 to 32,767 |
int |
−2,147,483,648 to 2,147,483,647 |
long |
−9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
float |
single-precision, to ±3.4028235E38 |
double |
double-precision, to ±1.7976931348623157E308 |
string |
UTF-8 text |
Append [] for an array of any of them: string[] tags.
Default values
Follow a field with = and a literal to give it a default. A field with a default is optional; a field
without one is required, and that distinction is what compatibility checking uses.
data OrderDefaults {
boolean planned = true
byte shortcutValue = 65
char shortcut = 'A'
short unreceivedReading = 12986
int quantity = 1
long tenYearTotal = 15329885886279
float approximatePi = 3.14
double preciselyPi = 3.1416
string label = "ABC"
}
Array defaults are a comma-separated list in braces:
data OrderSamples {
boolean[] votes = { true, false, true }
int[] sampleRange = { 518279, 400131 }
string[] notes = { "one", "two", "three" }
}
Two rules catch people out:
- A string literal used as a default must be 1 to 64 characters. That is a limit on the default value, not on what the field can carry at runtime.
- A numeric literal is range-checked against the field's type, so
byte level = 300is rejected.
Escapes in char and string literals are the usual ones: \t, \n, \r, \b, \f, \", \', \\,
plus \uXXXX.
Referring to other schemas
A field's type can be another schema in the same Published Language. So a shape declared once:
data FullName {
string givenName
string familyName
}
is referred to from another schema by name:
data ContactInformation {
FullName fullName
string emailAddress
}
A bare name like FullName must start with a capital letter and is resolved in the same category, at
its most recent version. To reach another category, or to pin a version, qualify it:
| Written | Means |
|---|---|
FullName |
Same category, latest version |
data.FullName |
The Data category, latest version |
data.Telephone[] |
An array of them |
data.Telephone:1.1.0 |
Pinned to version 1.1.0 |
data.Telephone:1.1.0[] |
An array, pinned |
Note: A version can only be pinned on the qualified form.
Telephone:1.1.0is not accepted; writedata.Telephone:1.1.0.
What a pin does when you export
A pin decides which version of that schema goes into the generated file. Pin data.Customer:1.0.0 and the
exported Customer has 1.0.0's fields, even when a newer version exists. It does so even when 1.0.0 is
Removed, since a retired version is kept precisely so a pin still resolves.
Because a generated file holds one type per schema, every reference to a schema has to agree on which version that is. The export dialog reports what it chose under Versions selected by a pinned reference, in three cases:
- A pin selected an older version than the newest one, so the file deliberately does not contain the shape you can see in the editor.
- A pin names a version that does not exist, so the newest was used instead.
- Two references disagree: one pins
1.0.0while another takes the latest. The newest is generated and both referrers are named, because only one of them can be satisfied in a single file. Resolve it by agreeing on a version, or by exporting the two consumers separately.
Comments
// to the end of the line and /* … */ both work anywhere whitespace does.
Validation as you type
The editor parses the specification while you write it and reports three kinds of problem:
- Syntax: with the line and column, from the grammar itself.
- Type literals: a default that does not suit its field: wrong kind of literal, out of range, a
charliteral that is not exactly one character. - Compatibility: how this version relates to the one before it. Covered below.
The server checks too. Moving a version from Draft to Published re-parses the specification and refuses the transition if it does not parse or if it is empty. A registry full of Published versions that say nothing is worse than one that made you finish writing them.
Note: Compatibility checking currently runs in the editor only. Syntax and emptiness are enforced on the server; a breaking change is not.
Versions and lifecycle
Semantic versioning
Versions are MAJOR.MINOR.PATCH, and the number is a promise about what changed.
| Increment | Means |
|---|---|
| Major | A breaking change. Consumers must act. |
| Minor | Something was added. Existing consumers keep working. |
| Patch | Nothing about the interface changed. |
Starting at 0.1.0
A schema's first version is 0.1.0, not 1.0.0, and that is deliberate.
Semantic versioning reserves 0.y.z for initial development, where anything may change at any time, and
gives 1.0.0 a specific meaning: this is the public contract. A first version is a Draft, which is by
definition "not yet something another context should depend on." So numbering it 1.0.0 would have the
version claiming a stable contract while its status said the opposite. The version number is the half that
travels to a consumer, so it is the half that has to be honest.
While the major version is 0, a breaking change is allowed on a minor bump. Going from 0.1.0 to
0.2.0 you may remove a field, change its type, or make it required, and it is reported as a warning rather
than refused. That is what 0.x is for: you are still working out what the contract should be.
Two things still hold below 1.0.0:
- The minor must move. A breaking change from
0.1.0to0.1.1is refused, and tells you to use0.2.0. Within0.1.xa reader can still expect the fields to be there. - Nothing else relaxes. Versions must still increase, and the specification must still parse and be non-empty before it can be Published.
When the contract settles, move to 1.0.0. That increment is how you say it is stable, so it is not
questioned even when the change itself was only additive; the warning about an unnecessary major bump
applies from 1.x upward, never to the graduation out of 0.x.
Note: Schemas created before this change started at
1.0.0and are unaffected. Only newly created schemas begin at0.1.0.
Publishing a 0.x version for the first time
The first time you publish a schema (while every version of it is still a Draft), and the version you are
publishing is still 0.x, you are asked:
First published version of OrderPlaced
Publishing says this schema is something other teams can depend on.
1.0.0is how semantic versioning states that;0.xmeans still taking shape.⚠️ Whichever you choose is what consumers pin, and it cannot be changed afterwards; a published version is frozen.
Keep 0.4.0 · Publish as 1.0.0
Choosing Publish as 1.0.0 renumbers the version you are publishing. It does not create a second version: the specification, the description and the version itself are unchanged; only the number differs, and only at this one moment.
Neither answer is the recommended one. Publishing a deliberate 0.x as an explicitly unstable preview
is a real choice, and the version number is what a consumer pins, so it is yours to make rather than ours to
make for you.
⚠️ This is the only time a version's number can change, and the only time it is offered. A version's number is normally fixed the moment it is saved; you get a new number by adding a new version, never by editing one. The exception holds here for one reason: nothing of this schema has ever been published, so nobody can have pinned any of its numbers yet. A moment later that stops being true, permanently. Closing the dialog without choosing publishes nothing, so you can come back to it.
You will not be asked when:
| Situation | Why |
|---|---|
The version is already 1.0.0 or higher |
There is nothing to offer. |
| Any version of the schema has ever left Draft | It has had a public life, so its numbers are settled. That includes one that went straight to Deprecated. |
The schema already has a 1.0.0 |
Two versions cannot share a number. |
| You are moving to Deprecated or Removed | Renaming a version while retiring it makes no sense. |
| Another schema pins this version | Renumbering would break its reference. See Referring to other schemas. |
Version status
Status moves in one direction only, and the server answers 409 to any attempt to move it backwards.
| Status | Meaning |
|---|---|
| Draft | Editable. Not yet something another context should depend on. |
| Published | Frozen. Other contexts may integrate against this version. |
| Deprecated | Still resolves, but consumers should move off it. |
| Removed | Retired. Kept so a pinned reference resolves rather than breaking. |
Only a Draft can be edited. A Published version is what somebody else pinned, and changing it would silently change the meaning of their integration. To change a Published schema, add a new version.
Nothing is ever deleted by status. A Removed version still resolves, because somewhere a build has it
pinned.
Compatibility rules
When you write a new version, it is compared against the one before it.
| Situation | Result |
|---|---|
| The version does not increase | Error |
| A breaking change, same major | Error, naming the major to move to |
| A breaking change, with a major bump | Allowed, listed as warnings so none is a surprise |
| Additive only, with a major bump | Warning: a minor would have done, and a major forces every consumer to act for no reason |
| Additive only, on a patch bump | Warning: a minor says "there is something new here"; a patch says the interface did not move |
| Below 1.0.0: a breaking change, with a minor bump | Allowed, as warnings. See Starting at 0.1.0 |
| Below 1.0.0: a breaking change, on a patch bump | Error, naming the minor to move to |
| 0.x → 1.0.0, additive only | Allowed, and not warned about; declaring stability is what a 1.0.0 is |
Breaking means one of three things:
- A field was removed.
- A field's type changed.
- A field went from optional to required; it lost its default, so every producer that omitted it now fails.
Adding a field is additive even without a default. Going the other way, from required to optional, is additive too.
Syncing from a Domain Model or Context Map
If a Published Language has a source model, Sync reads that model and offers to bring the language into line with it.
What becomes a schema
Messages and data shapes cross a boundary. Behavior does not.
| Element | Becomes |
|---|---|
| Domain Event | An Event |
| Command | A Command |
| Query, View | A Query |
| Aggregate, Entity, Value Object, Record, Struct, Type, User Role | Data |
| Domain Service, Repository, Factory, Policy, Saga | nothing |
A Repository or a Policy is a collaborator, not a payload; it has no wire form, so it produces no schema.
From a Context Map, the elements come from a bounded context's behavior model; naming a single context node when the language was created limits sync to that context, and otherwise every context contributes, with the same element appearing in two contexts counting once. From a Domain Model, they are its own elements.
Where the fields come from
The fields come from each element's Specifications property, which is Markdown. Write your field declarations in a fenced code block and sync copies them into the schema.
Emitted once payment clears.
```
string orderId
timestamp occurredOn
int quantity = 1
```
A Domain Event named OrderPlaced then produces:
event OrderPlaced {
string orderId
timestamp occurredOn
int quantity = 1
}
You need not write the event OrderPlaced { header; sync supplies it from the element's name and type. If
you do write one, it is used as-is, and a header that disagrees with the element's type is reported rather
than silently accepted.
A few things worth knowing, all of them covered in full in Editor basics:
- Declarations are gathered line by line, from every block. Prose between them reads as a comment, so you can explain each field as you go and still get all of them.
- Tag what is not a declaration. A
```jsonsample or a```bashcommand is skipped. An untagged block is assumed to be aimed at the model, because that is what a code block in a modeling tool usually is. - A bad line does not cost you the good ones. A declaration with a typo is reported by name, quoting the line and the reason, and the fields around it still come across.
- Write nothing at all and the schema is still created, correctly named and categorized, with an empty body.
Reviewing what sync would do
Nothing is applied without you accepting it, item by item. Three kinds of change are offered:
| Candidate | What it does |
|---|---|
| Create | A new schema, with the model's declarations, or empty if there were none. |
| Rename | The model's name differs from the schema's. Offered, never assumed. A schema may have been named away from the model on purpose. |
| Update | The model's declarations replace the specification of the schema's highest version, which must be a Draft. |
The dialog always reports what it read (for example, "Read 6 elements from Orders CM; 5 can become schemas") so that "nothing to change" can be checked rather than trusted. Those two look identical otherwise and are completely different problems.
Matching is by an element's logical identity, not its name, which is why renaming an element updates its schema instead of creating a second one.
Declining a change is remembered
A change you decline is not offered again on the next sync. Otherwise every sync would re-propose the same schema you have already decided against, and the review would become something to click past rather than read.
It is never hidden, though. Previously declined changes are collected at the bottom of the dialog under "3 changes you declined earlier (not offered again)", and each has an Offer again button that puts it straight back into the current review, where you can accept it like anything else. A decline you cannot see is a decision you cannot reverse.
⚠️ A decline covers that change, not that schema forever. If the underlying change is different next time, it is offered again, because it is a different decision:
| You declined | Later the model says | Offered again? |
|---|---|---|
Create OrderPlaced (no fields) |
the element now declares fields | Yes: the proposal changed |
Rename Order → PurchaseOrder |
rename Order → SalesOrder |
Yes: a different rename |
Rename Order → PurchaseOrder |
the same rename | No |
| Update a version's fields | the same fields | No |
| Update a version's fields | different fields | Yes |
So declining is safe: it quiets the change you have judged, and never the ones you have not seen yet.
The divergence report
After applying, a second stage lists what sync cannot do anything about. Sync only ever writes to the Published Language; it never edits a model.
| Divergence | Means |
|---|---|
| Frozen version | The model says something different, but the schema's highest version is Published, Deprecated or Removed. Add a new version if the model is right. |
| Absent in model | A schema has no matching element. Nothing was deleted; a Published version of it may be pinned by somebody's build. |
| Not a schema type | The element became a Policy or a Repository in the model, which produces no schema. The schema was left alone. |
| Unreadable specification | Something in the element's Markdown was evidently a declaration and did not parse. Quoted back, with the reason. |
A rename you decline lands here too, which is the honest place for it: the two names now differ on purpose.
While a version is open
If a sync updates the version you have open, the editor follows it when you have typed nothing, and keeps what you have typed when you have. In that second case it tells you the stored version has moved and offers to show it. It will not choose for you.
Release Packages
A Release Package is a named set of exactly one version of each schema. Export targets a package rather than whatever happens to be newest, so a release can be reproduced, named, and handed to someone.
Open Packages in the editor header. A new package starts from the highest non-Removed version of every
schema (what an export gives you today), and you change what you need to.
Give it a name that documents it: 2026.Q3, orders-v2, early-access.3. Letters, digits, dots, dashes and
underscores, starting with a letter or a digit. Names are sorted naturally, so orders-2 comes before
orders-10.
A package may leave a schema out
Set a schema to Not in this release and it is omitted. This matters more than it sounds: publishing a package requires every member to be finished, so without omission a single half-written schema would make the whole Published Language unreleasable.
The other common reasons are a schema with nothing releasable at all, and a schema this particular consumer does not need.
Every reference has to resolve
A package is checked as you build it, and the report is live:
- A field pinned to
data.Customer:1.0.0requires the package'sCustomerto be1.0.0. - A field written
data.Customerwith no pin is satisfied by whatever the package chose. Inside a package an unpinned reference means the package's choice, not the global latest. That is exactly what lets a whole release be held at an older set. - A reference to a schema the package omitted is an error. The generated file would not compile.
Open, Published, Deprecated
| Status | Means |
|---|---|
| Open | Being assembled. Its members may be Drafts and those Drafts keep changing. This is what you hand out for early access. |
| Published | Frozen. The membership can no longer change. |
| Deprecated | Still available, but consumers should move to a newer release. |
Status advances only, like a version's.
Note:
Openrather thanDraftis deliberate.Draftis a version status meaning "not yet something another context should depend on." That is the opposite of what an Open package is for.
Publishing requires every member to be Published or Deprecated. A Draft member is refused and named:
publish that version, choose another, or drop the schema from the release. Nothing is done to your versions
on your behalf.
There is one exception. A Removed version may be in a published package when another member pins it.
That is not a loophole: a Published version is frozen, so a pin inside it can never be edited away, and
without the exception a release containing it could never be published again once its dependency retired.
The package tells you when this applies.
Once published, a package is frozen; copy it to start the next release from the same selection.
Exporting
Export in the editor offers two shapes, and a Release selector when the language has packages. The selector defaults to Latest: the highest version of every schema, which is what you get when you have no packages at all.
| What you get | |
|---|---|
| Source code | One file, in one language. Paste it into a project. |
| Bundle (.zip) | The whole release as files: schemas, unpack scripts, and optionally the language bindings too. |
| Push to a registry | Sends the schemas straight to a Schema Registry you have set up. |
Source code
One file per export, named after the language. Every language requires a package or namespace, and export is refused until you set one.
| Language | Namespace field | What it becomes |
|---|---|---|
| Java | Package (required) | package com.acme.orders; |
| C# | Namespace (required) | namespace Com.Acme.Orders |
| Go | Package name (required) | the last segment, orders |
| Rust | Namespace (required) | pub mod com_acme_orders |
| TypeScript | Namespace (required) | export namespace Com.Acme.Orders |
| JavaScript | Namespace (required) | nested const objects |
| Ruby | Namespace (required) | nested module Com / Acme / Orders |
| PHP | Namespace (required) | namespace Com\Acme\Orders\Events |
| Python | Namespace (required) | the package path to put the file at, com/acme/orders/ |
Note: Ruby, PHP and Python do not strictly need one. A class is legal at the top level in all three, and a Python module is already a namespace. It is required anyway, because a generated file full of top-level classes collides with everything else the application has loaded, and Rails, PSR-4 and Python packages all nest by directory in any case. Python is the one language where the namespace cannot appear in the file as code, so the generator writes the intended package path into the module's docstring instead.
Types in the dynamically-typed languages
Seven of the nine put the schema's types in the code itself. Ruby and JavaScript cannot; neither has type declarations, so the types travel as documentation comments instead:
# @param [Time] occurred_on
# @param [Integer] quantity
def initialize(occurred_on:, quantity: 1)
/**
* @param {string} id
* @param {number[]} counts
*/
constructor(id, counts) {
Ruby uses YARD, JavaScript uses JSDoc, and they work the same way: the interpreter ignores them
completely, while editors read them for autocomplete and hover; VS Code and RubyMine do this out of the box,
and JSDoc is what TypeScript's checkJs reads if you turn it on. Nothing is enforced at runtime.
They are comments rather than a checked type system on purpose. Ruby's alternative is
Sorbet, whose sig blocks are method calls; the file will not load without the
sorbet-runtime gem installed, and a generated contract should not add a dependency to your application.
⚠️ Ruby's constructor takes keyword arguments; JavaScript's are positional. So in Ruby the field names
are part of the call and cannot be transposed, while in JavaScript new OrderPlaced(id, counts) depends on
getting the order right. That is what the JSDoc is there to help your editor catch.
PHP and Python declare their types, but enforce them very differently
PHP checks every argument as it is passed. The generated file opens with declare(strict_types=1), so
handing a string to a field the schema declares as int raises a TypeError at the call rather than
surfacing three layers away. Fields are readonly promoted constructor properties, which means they are set
once and the property list cannot drift from the constructor signature.
public function __construct(
public readonly string $orderId,
public readonly \Com\Acme\Orders\Data\Customer $buyer,
public readonly int $quantity = 1,
) {
}
Python's annotations are checked by mypy or pyright, and by nothing at all when the program runs. The generated classes are frozen dataclasses, so a field cannot be reassigned after construction, but a value of the wrong type simply goes in.
@dataclass(frozen=True)
class OrderPlaced:
order_id: str
buyer: Data.Customer
quantity: int = 1
Nothing here depends on pydantic. It would validate at runtime, at the price of putting a package into your project that you did not choose, and a generated contract should be droppable into any Python program.
Two details worth knowing:
- PHP's
arraycarries no element type.int[] countsdeclares as a plainarray, with the element type in a@param int[] $countsdocblock that PHPStan, Psalm and your editor read. - Fields with a default value are moved to the end of the constructor in both languages. A required parameter after an optional one is deprecated in PHP, and a Python dataclass refuses to be created at all.
Without a package, export takes the highest non-Removed version of every schema, whatever its status.
Not "the Published ones": a language still being drafted would export as nothing at all, which is not useful,
and a Removed version is retired by definition. Choose a release instead and the package decides: which
versions, and which schemas are in the file at all.
Pushing to a Schema Registry
Instead of downloading anything, DomoModeler can register your schemas directly in a Schema Registry: the service your producers and consumers already resolve schemas through.
The registries you can push to
| Registry | Formats it accepts | What you need before you start |
|---|---|---|
| Confluent Cloud | Avro, JSON Schema | A Schema Registry enabled in your environment |
| Google Cloud Managed Service for Apache Kafka | Avro | A schema registry created in your project |
| Google Cloud Pub/Sub | Avro | Nothing. Schemas live directly in the project |
| Azure Schema Registry (Event Hubs) | Avro, JSON Schema | ⚠️ A schema group, created beforehand |
| AWS Glue | Avro, JSON Schema | A Glue registry, created beforehand |
| Amazon EventBridge | ⚠️ JSON Schema only | An EventBridge registry, created beforehand |
Self-hosted registries (Apicurio, or Confluent Platform on your own network) are not on this list, and that is deliberate. DomoModeler only reaches addresses it can construct itself, which is what stops it being pointed anywhere unexpected. For those, use the Bundle instead: it registers exactly the same schemas, in the same order, under the same names, from inside your own network. Nothing is lost but the button.
Setting one up
Choose Push to a registry, then Manage…. You pick your registry type from a list and fill in a few named fields; there is no box to type a URL into. DomoModeler builds the address from what you enter and shows it back to you, read-only. That is a safety feature: because no field anywhere accepts an address, one cannot be aimed somewhere it should not go.
Give it a name you will recognize; that name is what you pick at export time.
Verify before you push
Verify answers two separate questions, and it is worth reading both lines:
- Address reachable: a registry really is at that address, and for Azure, that the schema group exists.
- Credential accepted: the key or token you supplied actually works.
You can get the first without the second. A registry that answers "no" to your credential is still proof the address is right, so the two are reported separately rather than as one tick.
If you supply no credential, the second line is absent. It is unchecked, which is not the same as refused.
Where to find your address and credentials
Credentials are typed at the moment you push, used once, and never stored. You will enter them again next time. That is why there is nowhere in DomoModeler to save them.
Confluent Cloud
| Field | Where it comes from |
|---|---|
| Cluster ID, Region, Cloud provider | Your endpoint URL: Console → your environment → Stream Governance API → Endpoint. Or run confluent schema-registry cluster describe |
| API key + secret | Console → environment → Stream Governance → API keys, or confluent api-key create --resource lsrc-… |
⚠️ Two things catch almost everyone here.
The ID is the one in your endpoint URL, not your cluster ID. describe prints both: a Cluster ID like
lsrc-v71onqz and an Endpoint URL like https://psrc-1ryeo07.us-west-2.aws.confluent.cloud. They are
different strings, and it is the second you take apart. You cannot work one out from the other.
Your API key must belong to the Schema Registry, not to Kafka. Confluent scopes keys to a resource, and a
key made for your Kafka cluster (lkc-…) is rejected here with an unhelpful "unauthorized". Make the key
against the Schema Registry cluster (lsrc-…).
Also: an address like pkc-abcde.us-west-2.aws.confluent.cloud:9092 is the Kafka bootstrap server, for
producers and consumers. It sits on the same console page and is not a registry address.
Google Cloud: Managed Service for Apache Kafka
| Field | Where it comes from |
|---|---|
| Project ID, Location, Schema registry ID | Console → Managed Service for Apache Kafka → Schema registries |
| Access token | gcloud auth application-default print-access-token |
Google Cloud: Pub/Sub
| Field | Where it comes from |
|---|---|
| Project ID | Console → Pub/Sub → Schemas |
| Access token | gcloud auth print-access-token |
Both Google registries take a single access token rather than a key and secret, so you will see one field rather than two. Tokens last about an hour, which suits entering one per push.
Azure (Event Hubs)
| Field | Where it comes from |
|---|---|
| Namespace, Schema group | Portal → your Event Hubs namespace → Schema Registry |
| Access token | az account get-access-token --resource https://eventhubs.azure.net --query accessToken -o tsv |
⚠️ Create the schema group first. Azure will not create one for you from here, and a missing group is the usual reason a push fails. Verify checks for it, so a green "Address reachable" means the group is there.
⚠️ A valid token is not enough on its own. The identity needs the Schema Registry Contributor role on the namespace. Without it you will see "Address reachable" and "Credential refused" together.
AWS Glue and Amazon EventBridge
| Field | Where it comes from |
|---|---|
| Region, Registry name | Glue: Console → AWS Glue → Stream schema registries. EventBridge: Console → EventBridge → Schemas → Registries |
| Access key ID + secret access key | IAM → Users → Security credentials, or aws iam create-access-key |
Your IAM user needs glue:CreateSchema and glue:RegisterSchemaVersion for Glue, or schemas:CreateSchema
and schemas:UpdateSchema for EventBridge.
⚠️ EventBridge accepts JSON Schema only; it is the one registry on this list that cannot take Avro, so the format choice differs there.
What a push actually does
Schemas go up in dependency order, because a registry that supports references has to be told about a dependency before anything can point at it. Every schema is reported by name: registered, or not sent and why. Nothing is silent.
Pushing the same schemas again when nothing has changed is safe, and is not an error; most registries simply recognize that they already have them.
Your semantic version travels with each schema, though where it lands depends on the registry. A breaking
change (a new major version) is registered under a new name ending .v2, so consumers of version 1 are
undisturbed. See "Version numbers appear in two different forms" below, which explains the same idea for the
bundle.
The bundle
A bundle is the whole release as a folder you can commit, unzip into a build, or hand to another team. You do not need a schema registry to find it useful; tracking schemas in git is a first-class reason to use it.
Orders-2026.Q3/
README.md
schemas/
avro/com/acme/orders/event/OrderPlaced.v2_1_0.avsc
json-schema/com/acme/orders/event/OrderPlaced.v2_1_0.schema.json
src/
java/OrdersSchemas.java
typescript/orders-schemas.ts
domo-pl-schemas-unpack.sh
domo-pl-schemas-unpack.ps1
domo-pl-schemas-unpack.cmd
What it contains
- Schema files, in Avro, JSON Schema, or both. Each file is self-contained: a schema that refers to another has that other schema embedded, so the file stands alone in a repository.
- Source code, optionally, in any of the nine languages. Each goes in its own subdirectory, so you can include several.
- Three unpack scripts, always. See below.
Version numbers appear in two different forms, on purpose
| Carries | |
|---|---|
| The filename | The full semantic version: OrderPlaced.v2_1_0.avsc |
| The registry subject | The major only: com.acme.orders.event.OrderPlaced.v2 |
| The type name itself | Never versioned: com.acme.orders.event.OrderPlaced |
A filesystem has no notion of compatibility, so the full version in the path costs nothing and makes a git history readable at a glance. A registry subject is exactly where compatibility is checked, so holding one major per subject is what lets the registry compare 2.0.0 against 2.1.0. A major bump, which is a breaking change, correctly becomes a different subject.
The type name stays fixed because it is what other schemas reference and what the registry is keyed by. Versioning it would break both, and rename the type in every generated language binding.
The unpack scripts
Every bundle carries all three, because whoever unzips it may not be on the same operating system as whoever exported it.
| Script | For |
|---|---|
domo-pl-schemas-unpack.sh |
macOS, Linux, WSL, git-bash |
domo-pl-schemas-unpack.ps1 |
Windows PowerShell (5.1 or later) |
domo-pl-schemas-unpack.cmd |
Windows batch: runs the PowerShell one for you |
They do three things.
Copy the schemas into your build:
./domo-pl-schemas-unpack.sh -o ./src/main/resources/schemas
Copy the generated source into your source tree:
./domo-pl-schemas-unpack.sh --source-out ./src/main/java
It is separate from -o deliberately; schemas belong in a resources directory and source belongs in a source
directory. If the bundle holds more than one language, add -l java to say which; with only one, it is
inferred and you do not need the option.
Register the schemas with a Schema Registry, in dependency order, so a schema is always registered before anything that references it:
./domo-pl-schemas-unpack.sh --registry https://schema-registry.internal:8081
./domo-pl-schemas-unpack.sh --registry https://psrc-xxxxx.confluent.cloud --user KEY:SECRET
On Windows, use the same options with PowerShell naming:
domo-pl-schemas-unpack.cmd -Out .\schemas
domo-pl-schemas-unpack.cmd -Registry https://schema-registry.internal:8081
Add --dry-run (or -DryRun) to any of them to see what would happen without changing anything. All of them
are safe to run twice, which is what makes them usable as a build step.
Every option
The shell script and the PowerShell script take the same options under each platform's own naming, so each
row below gives both: .sh: for domo-pl-schemas-unpack.sh, and win: for domo-pl-schemas-unpack.ps1
and the .cmd wrapper, which passes whatever you give it straight through to the .ps1.
| Option | Takes | What it does |
|---|---|---|
.sh: -o, --outwin: -Out |
a directory | Copies the schema files into that directory, creating it if needed. The schemas/<format>/ prefix is dropped, so you get the files themselves rather than the bundle's folders. |
.sh: --source-outwin: -SourceOut |
a directory | Copies the generated source into that directory. Separate from --out on purpose: schemas belong in a resources directory and source belongs in a source directory, and one option serving both would put .java files under src/main/resources. |
.sh: -l, --languagewin: -Language |
one or more language names | Chooses which language's source --source-out copies. Only needed when the bundle holds more than one. With a single language it is inferred. Name several by separating them with a comma and no spaces (-l java,csharp,go), or take everything the bundle has with all. The shell script also accepts the option repeated (-l java -l csharp); PowerShell does not, so the comma is the form worth learning. |
.sh: --registrywin: -Registry |
a registry URL | Registers every schema with that Schema Registry, in dependency order, so a schema is always registered before anything that references it. Posts to <url>/subjects/<subject>/versions. |
.sh: --userwin: -User |
user:password |
The registry credential, for a registry that needs one. On Confluent Cloud this is your API key and secret as KEY:SECRET. Only meaningful alongside --registry. |
.sh: --dry-runwin: -DryRun |
— | Prints every copy and every registration it would perform and changes nothing: no files written, no requests sent. Works with all three actions. |
.sh: -h, --helpwin: (none) |
— | Prints the usage summary. Shell only. In PowerShell, run the script with no options to get the same summary. |
At least one of --out, --source-out or --registry is required. Without one there is no work to do, and
the script says so and prints the usage rather than exiting quietly as though it had succeeded.
Naming several languages. One bundle often feeds more than one codebase: a C# service and a Java one reading the same events. Rather than running the script once per language, list them:
# one language
./domo-pl-schemas-unpack.sh --source-out ./generated -l java
# two, comma-separated
./domo-pl-schemas-unpack.sh --source-out ./generated -l csharp,java
# one language
.\domo-pl-schemas-unpack.cmd -SourceOut .\generated -Language java
# two, comma-separated
.\domo-pl-schemas-unpack.cmd -SourceOut .\generated -Language csharp,java
The order you list them in does not matter; csharp,java and java,csharp copy the same two files.
⚠️ Commas, with no spaces. -Language java csharp does not name two languages. PowerShell reads the
second word as the next unnamed option rather than as part of the list, and older versions of these scripts
silently treated it as the schema output directory. They wrote the schemas into a folder called csharp. The
scripts now stop and tell you to use a comma instead, but the comma is what you want either way.
⚠️ Repeating the option works only in the shell script. -l java -l csharp is fine in bash; the
PowerShell equivalent -Language java -Language csharp fails with "Cannot bind parameter because parameter
'Language' is specified more than once". That comes from PowerShell itself, before the script runs, so it
is not something the script can accept or explain away. The comma form works in all three scripts, which is
why it is the one to use.
Use -l all (or -Language all) to take every language in the bundle without naming them.
The three actions combine. They are independent, so one run can do all of it: copy the schemas into your resources, the bindings into your source tree, and register everything:
./domo-pl-schemas-unpack.sh \
-o ./src/main/resources/schemas \
--source-out ./src/main/java \
--registry https://schema-registry.internal:8081
What the exit code means, since a build step is usually judged on it:
| Code | Meaning |
|---|---|
0 |
Everything asked for succeeded. |
1 |
The work failed partway: a registration was rejected, the bundle has no source at all, it has none for the language you named, or curl / python3 is missing. |
2 |
The command itself was wrong: an unknown option, no action given, or several languages present with none chosen. |
--registry needs curl and python3 on the path. The script checks for both before it sends
anything, so a missing tool stops it at the start rather than halfway through registering. python3 is only
used to JSON-encode the schema document into the request body, and it was chosen over jq because a build
machine is far likelier to already have it.
⚠️ Registration stops at the first failure. Schemas go up in dependency order, so continuing past a failed one would try to register schemas whose reference the registry has not accepted. The script prints the subject that failed and the registry's own message, and everything registered before it stays registered. Fixing the cause and re-running is the intended recovery. Re-registering an unchanged schema is not an error.
Note: Your credential is passed straight to the registry and is never printed by the script or written anywhere. If a registration fails, the script reports the schema that failed and the registry's message.
Note: The
.cmdwrapper exists so a downloaded.ps1runs without Windows blocking it. To run the.ps1directly,Unblock-Fileit first.
If PowerShell prints errors that have nothing to do with the script
Running the .ps1 yourself can produce a wall of red before the script even starts. Most often it is a
CommandNotFoundException naming a tool you were not expecting, with a path like
Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 in it.
That is your PowerShell profile, not the unpack script. PowerShell runs that profile before anything else,
so whatever it fails to do gets reported first and looks like the script failing. A common example is
fnm env --use-on-cd, from Fast Node Manager, in a profile written on a
machine where fnm is installed but run on one where it is not.
The script itself usually still succeeds. To see that clearly, skip the profile. That is exactly what the
.cmd wrapper does for you, and why it is quiet:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\domo-pl-schemas-unpack.ps1 -SourceOut .\generated -Language csharp,java
That is the .ps1 invoked directly, so it carries the whole incantation: -NoProfile,
-ExecutionPolicy Bypass and -File. The .cmd wrapper supplies all three for you, which is why its
equivalent above is a single short line.
Use -NoProfile whenever you invoke the .ps1 directly, in a build step especially: a build should not
depend on whatever happens to be in the profile of the account it runs under.
A namespace matters more here
Export works without a namespace, but every schema name loses its organizational prefix: event.OrderPlaced
rather than com.acme.orders.event.OrderPlaced. That is fine inside your own repository and a problem in a
shared registry, where it will collide with any other team publishing an OrderPlaced. The export tells you
so rather than refusing; a registry push will refuse.
The namespace is seeded from the language's own, and you can override it for a single export without changing the stored value.
Properties and the namespace
Properties holds the name, the namespace and a description. The dialog is the same one the dashboard row menu opens, except that the editor hides the name field; you rename from the title in the header there.
The namespace is the coordinate consumers resolve this language by, and it is what generated code
declares: Java emits package <namespace>;, C#, Rust and Ruby capitalize its segments, Go takes the last
one.
It is validated, because a value like Orders Domain or 123-orders produces code that fails in somebody's
build rather than in the editor that accepted it. Dotted segments, each starting with a letter or
underscore: com.acme.orders. Leaving it empty is fine; a language may not have chosen one yet.
Collaboration
A Published Language is edited live, like a model. Presence shows who else is in it, and the save status sits in the header. Two people editing different schemas never collide; two people editing the same Draft see each other's work.
Not here yet
| Encrypted export and import of a whole language | The Export item in the dashboard row menu. A Published Language is not a model, so it does not inherit that archive format. |
| Export to a third-party registry | AWS EventBridge and the Azure Schema Registry are the target pair. This is how a Published Language will reach a consumer's runtime. |
| A public API | There is no HTTP surface for retrieving schemas or creating versions from a build pipeline. |
| Server-side compatibility checking | The editor asks before a breaking change; the server does not re-check it. |
| Remembered sync declines | A candidate you decline is offered again on the next run. It shows in the divergence list meanwhile. |
| Multiple sync sources | A language may record several source models, but sync reads the first. |