Message Versioning
Evolving message contracts in distributed clusters
Distributed systems evolve. Services gain features, data models change, and deployments happen gradually. During a rolling upgrade, some nodes run new code while others still run the old version. A message sent from a new node must be understood by an old node, and vice versa.
EDF gives you two ways to handle this, and which one is right is a business decision, not a technical default. Strict by default: a message is its exact Go type, so changing a struct creates a new, incompatible type, and every change is explicit and caught at compile time. Or opt into schema evolution: EDF tolerates fields appended to the end of a struct, so a node that has not learned a new field keeps working. Strict typing buys deliberate, visible change control; evolution buys less coordination during rolling deploys. Which matters more is a property of your domain, not of the framework.
This article covers both - how to version messages explicitly, and when schema evolution fits instead - so your cluster handles upgrades gracefully.
Explicit Versioning
The strict strategy is the default. Unlike Protobuf or Avro, EDF does not provide automatic backward compatibility here: there are no field numbers, and every registered field is encoded positionally and always present on the wire. A struct is its type. Change the struct - create a new type.
A pointer field (*int) is still optional in the value sense - nil or a value - but the field itself stays part of the type on both sides. That is not the same as a Protobuf optional field, which can be absent from the message entirely; absence-of-field is exactly what the strict default does not allow (and what schema evolution adds, only for trailing fields). The alternative is covered in Schema Evolution below; pick whichever fits your domain.
The strict approach is straightforward: create a new type for each version.
// Version 1
type OrderCreatedV1 struct {
OrderID int64
}
// Version 2 - new field
type OrderCreatedV2 struct {
OrderID int64
Priority int
}Both types coexist in the codebase. The receiver handles whichever version arrives:
All message types must be registered with the network stack before connection establishment. The declarative form lives in ApplicationSpec.Network:
For dynamic registration (types resolved at runtime) the imperative a.Node().Network().RegisterTypes(...) from Load is also supported. For details on the type registry and the package-level edf.RegisterTypeOf API, see Network Transparency.
Versioning Strategies
There are two ways to organize versioned types: version in the type name or version in the package path. Both work with EDF. Choose based on your team's preferences.
Important: Do not confuse package path versioning with Go modules v2+. Go modules v2+ requires changing both go.mod and all import paths when bumping major version (company.com/events/v2). This forces all consumers to update imports simultaneously, creates diamond dependency problems, and generally causes more pain than it solves. Keep your module below v2.0.0 to avoid triggering this mechanism.
Version in Type Name
All versions live in the same package:
Handler uses type names directly:
Advantages:
Single import for all versions
All versions visible in one place - evolution is clear
One registration file for all types
Simpler directory structure
Version in Package Path
Each version is a separate package:
Handler uses package aliases:
Advantages:
Clean type names without version suffix
Familiar to Protobuf users
Clear directory separation between versions
Removing a version means deleting a directory
Module Organization
For projects where message versions evolve in parallel, place go.mod in each domain directory:
The /v1/ and /v2/ segments are in the middle of the module path, not at the end. Go only applies v2+ import path requirements when /vN is the final path element, so company.com/messaging/v1/events is safe.
This structure allows:
V1 to continue receiving new message types while V2 is developed
Each domain to have isolated dependencies
Clean removal - deleting a directory removes the module entirely
Tagging submodules: Git tags for nested modules must include the path prefix. For module company.com/messaging/v1/events located at v1/events/, use tag v1/events/v0.1.0, not just v0.1.0.
Which to Choose
This documentation uses version in type name for examples. The approach keeps related versions together and requires less import management. However, version in path is equally valid if your team prefers cleaner type names.
Whichever you choose, stay consistent across the codebase.
The versioning mechanism is clear. The next question: where should these types live, and who controls their evolution?
Message Scopes
The answer depends on how the message is used. Not all messages are equal - some travel between two specific services, others broadcast across the entire cluster.
Private Messages
Direct communication between specific services. Request/response patterns between known parties.
Owner: receiver
Payment Service defines what it accepts. Order Service adapts to Payment's contract.
Cluster-Wide Events
Domain events published to multiple subscribers. Any service can subscribe.
Owner: shared repository
Events represent domain facts, not service-specific contracts. Ownership belongs to a shared module that all services import.
For event publishing patterns, see Events.
Ownership Rules
Scope determines ownership. Who decides when to create V2? Who approves changes?
Private messages
Receiver
receiver-api/
Receiver team
Cluster-wide events
Shared
events/
All consumers
The receiver owns private contracts because it implements the logic. Multiple senders may use the same contract, but they all adapt to what the receiver accepts. This follows the Consumer-Driven Contracts pattern. Events are shared because they represent domain facts, not service-specific APIs.
Private Contract Ownership
Payment Service owns its API contract:
Order Service imports and uses it:
Payment team decides when to create V2. Order team adapts.
Cluster Event Ownership
Events require broader coordination:
Breaking changes require sign-off from all consumers.
Repository Organization
With ownership defined, the repository structure follows naturally. Private contracts live with their receivers. Cluster-wide events live in a shared module.
Version in Type Name
Version in Package Path
Registration Helper
All message types must be registered with the network stack before connection establishment. During handshake, nodes exchange their registered type lists which become the encoding dictionaries. Registration happens from an application's Load callback, which runs after the network stack is initialized but before any traffic. Its signature is Load(args ...any) (gen.ApplicationSpec, error) - the node is not an argument, it comes from the embedded app.Application as a.Node(), which is what the example below does. There are two approaches: a centralized helper exported by the shared module, or manual registration per client.
Centralized helper exposes a single function that the consumer's application calls from Load:
Each consumer calls it from its application:
The shared events module owns the canonical list of types. Consumers register them all without having to enumerate each type, so there is no risk of forgetting one. RegisterTypes accepts a slice in any order and resolves nested-type dependencies internally.
Manual registration means each client registers only the types it uses. This gives more control but introduces risk: a missing registration is only detected at runtime, surfacing as "no encoder for type" when sending or "unknown reg type for decoding" when receiving. For most projects, centralized registration is simpler and safer. Choose based on your needs.
For message isolation patterns within a single codebase, see Project Structure.
Compatibility Rules
By default EDF enforces strict type identity: change a struct's field count, order, or types and it is a new, incompatible type. Schema evolution (off by default, covered below) relaxes this for the trailing fields - you may add to or remove from the end.
Rename a field (same type)
Compatible
Compatible
Add a field at the end
New version
Compatible
Remove a field from the end
New version
Compatible
Insert, reorder, retype, or remove a field elsewhere
New version
New version
Field names are never on the wire - EDF encodes fields positionally - so renaming a field while keeping its type and position is wire-compatible in both modes. Make it a new version only when the rename signals a changed meaning, not a mechanical rename.
Removing a trailing field decodes cleanly by the same mechanism, but unlike appending it is a genuine deletion, not a free change. A node that still carries the field reads it as its zero value the moment an upgraded node stops sending it - and if business logic there depends on the field, it silently reads zero. That is the footgun. Remove a field only after it is deprecated and confirmed unused (see Version Lifecycle); evolution only guarantees the removal will not break decoding mid-rollout, not that it is semantically safe.
Under the strict default, every other change requires explicit versioning. This is the opposite of Protobuf/Avro, where adding an optional field is silently compatible - and that difference is the choice you are making, not a verdict on either approach.
Consider the implicit style: you add an optional Priority field and everything "just works" - until you spend three days debugging why orders aren't prioritized correctly, because half your cluster sends the field, half ignores it, and receivers default the missing value to zero with nothing in the logs. Strict typing makes that class of bug impossible: the receiver either handles OrderV2 with its Priority, or it doesn't, and you know which at compile time.
That safety has a price - coordination. Every additive change, even a harmless new field, means a new type and a coordinated rollout. For a fast-moving service that mostly appends fields, that ceremony can cost more than the risk it removes. Schema evolution is built for exactly that case: you accept the zero-default behavior for appended fields in return for dropping the per-field versioning. Neither model is universally correct - how much you value explicit change control over deployment velocity is a business call, and EDF lets you make it per connection.
Schema Evolution
When your domain leans toward deployment velocity, schema evolution removes the per-field versioning ceremony for the append case. Enable it with the EnableSchemaEvolution network flag. Like the other capability flags, it is negotiated during handshake: evolution is active only when both ends enable it, so a connection to an older node, or one that left the flag off, stays strict.
With evolution active, you may add fields to the end of a registered struct without minting a new type. The type keeps its identity, and old and new nodes interoperate through the rolling deploy:
A node that does not know the appended field skips it (a new sender, an old receiver).
A node that expects a field an older sender did not include reads it as its zero value (an old sender, a new receiver).
During the rollout a node still running the old OrderCreated ignores Priority; an upgraded node receiving an old message sees Priority as 0. No new type, no handler branch, no anti-corruption layer.
Limitations
Schema evolution covers the trailing fields, and nothing else:
Trailing fields only. The fields common to both versions must match in type and order; versions may differ only at the end. Appending is the common case; trimming the last field also works on the wire, but that is a genuine deletion with a footgun (old nodes read the dropped field as zero - see the note under the table above). Inserting, reordering, retyping, or removing a field anywhere but the end is a breaking change - create a new version.
Both nodes must enable it. A connection where either side has the flag off runs strict, so an older node never receives a form it cannot parse.
No mismatch detection. Evolution tolerates a difference in the trailing field count; it does not verify that the shared leading fields actually match. A change to those leading fields made by mistake (a reorder, a type change) is not caught - it silently misreads, exactly the zero-default class of bug the strict default prevents. The trailing-only discipline is on you.
Size cap per struct. With evolution on, a single encoded struct is bounded at just under 4GB. The strict default has no such per-struct cap.
Because of the last two points, evolution is a deliberate trade, not a free upgrade: you take on the trailing-only discipline (and its footgun) to drop per-field versioning. For anything beyond appending, or for high-stakes contracts where every change must be a visible compile-time decision, keep the strict default and version explicitly. For how EDF encodes registered types, see Network Transparency.
Version Lifecycle
With compatibility rules clear, how do versions evolve over time?
When to Create New Version
Any change from the compatibility table above requires a new version. Additionally, create a new version when changing field semantics (same type, different meaning).
Deprecation
Mark deprecated versions:
Log when receiving deprecated versions:
Removal
Remove only when:
All senders upgraded to V2
Monitoring confirms zero V1 traffic
Deprecation period passed
Remove in order:
Stop accepting (return error for V1)
Remove from registration
Delete type definition
Rolling Upgrades
Back to the scenario from the introduction: you're deploying a new version, nodes restart one by one, and for some time the cluster runs mixed code versions. How do you handle this?
Upgrade Strategy
Deploy V2 types to shared module
Update receivers to handle V1 and V2
Rolling restart receiver nodes
Update senders to send V2
Rolling restart sender nodes
Deprecate V1 after all nodes upgraded
Remove V1 after deprecation period
Coexistence Period
Receivers must support both versions during the upgrade window.
For deployment patterns with weighted routing, see Building a Cluster.
Anti-Corruption Layer
Supporting multiple versions means your handler has multiple code paths. As versions accumulate, this becomes messy. The Anti-Corruption Layer pattern isolates version translation:
Use in handler:
Single implementation handles V2. ACL converts V1 to V2. When V1 is removed, delete the ACL function - no changes to business logic needed.
Contract Testing
With version handling and ACL in place, how do you verify it actually works? Contract tests verify compatibility:
unit.Spawn puts the actor on a mock node and hands back a *unit.Subject: SendMessage delivers into it from a sender you name, and the Should* family asserts on what it did. Reach for unit.StartNode(t, "test@localhost", gen.NodeOptions{}) first when the node name or its options matter to the test. See Unit Testing.
Test ACL conversion:
Run contract tests in CI before merging changes to shared modules.
For actor testing patterns, see Unit Testing.
Naming Conventions
Consistent naming makes code self-documenting. When you see a type name, you should immediately know: is this async or sync? Is it a request or event? What version?
Async Messages
Prefix with Message, suffix with version:
The prefix signals fire-and-forget semantics. When reading code, MessageXXX means no response is expected. If someone writes Call(pid, MessageOrderShippedV1{}), the mismatch is immediately visible.
Sync Messages
Use Request/Response suffix:
Paired naming makes contracts explicit. ChargeRequest implies ChargeResponse exists. The caller knows to expect a result.
Events
Domain events use past tense without prefix:
Events describe facts that already happened, not requests for action. Past tense (Created, Received) distinguishes them from commands (Create, Charge).
Version Suffix
If using version in type name strategy, always suffix with version number:
If using version in path strategy, the package path carries the version and type names stay clean.
Common Mistakes
These patterns emerge repeatedly in production systems. Avoid them:
Changing existing type instead of creating new version
This is a mistake only under the strict default. With Schema Evolution enabled on both nodes, appending Priority at the end keeps the same type and stays compatible - that is the whole point of opting in. Inserting, reordering, retyping, or removing a non-trailing field is still a breaking change either way.
Forgetting to register new types
Long coexistence periods
Supporting V1 for months creates maintenance burden. Set clear deprecation deadlines and enforce them.
Registering after connection established
Types must be registered before connections are formed. Dynamic registration requires connection cycling.
Summary
Message versioning in EDF is explicit by default: no hidden compatibility rules, no runtime surprises. When your domain favors deployment velocity over that control, schema evolution opts into append-compatibility per connection. Both are valid - the choice is a business one.
Nature
Service API contract
Domain fact
Owner
Receiver (implements logic)
Shared (belongs to domain)
Module
receiver-api/
events/
Changes
Receiver team decides
All consumers coordinate
Key principles:
Choose strict versioning or schema evolution by what your business needs, not by default
Version in type name or package path, never in Go module path
Receiver owns private contracts
Shared repository for domain events
Test version compatibility
Set deprecation deadlines
Use ACL to isolate version translation
With schema evolution, keep changes append-only - the discipline is not enforced
Last updated
Was this helpful?