Migrating NATS Event Contracts Without Breaking Consumers
A practical strategy for evolving NATS subjects and event naming while keeping producers and consumers compatible during transition windows.
When I first attempted to rename a NATS subject in our document processing pipeline, I learned firsthand why implicit contracts are so dangerous. I pushed a config change that moved documents.ocr.completed to a new namespace, and within minutes our notification service went silent — no errors in logs, no exceptions thrown, just complete silence. It took me an embarrassing amount of time to realize what had happened: the consumer was still listening on the old subject, happily waiting for messages that would never arrive. That incident convinced me to build a proper migration strategy with transition windows, and I have not lost a message to a contract change since.
Event-driven architectures rely on implicit contracts: producers publish to a subject, consumers subscribe to it, and the payload schema connects them. When you need to rename subjects, restructure payloads, or consolidate event streams, you face a distributed coordination problem. This article presents a safe migration strategy using transition windows and subject resolution.
Why Event Contracts Break
Unlike REST APIs with versioned URLs, messaging contracts are often implicit. A producer publishes to documents.ocr.completed and a consumer subscribes to the same subject. Rename the subject, and the consumer silently stops receiving messages — no compile error, no 404, just silence.
Common triggers for contract changes:
- Reorganizing subject namespaces (
documents.*toarchives.documents.*) - Splitting a catch-all subject into granular ones
- Changing payload shapes (adding/removing fields)
- Consolidating multiple subjects into one with a discriminator field
The Transition Window Pattern
Instead of a hard cutover, run parallel subjects during a transition window:
sequenceDiagram
participant P as Producer
participant OLD as Old Subject
participant NEW as New Subject
participant CA as Consumer A
participant CB as Consumer B
rect rgb(30, 40, 68)
note over P,CB: Phase 1 — Dual Publish
P->>OLD: publish(v1)
P->>NEW: publish(v2)
CA->>OLD: consume(v1)
CB->>OLD: consume(v1)
end
rect rgb(30, 50, 58)
note over P,CB: Phase 2 — Consumer Migration
P->>OLD: publish(v1)
P->>NEW: publish(v2)
CA->>NEW: consume(v2)
CB->>OLD: consume(v1)
end
rect rgb(40, 50, 38)
note over P,CB: Phase 3 — Stop Old
P->>NEW: publish(v2)
CA->>NEW: consume(v2)
CB->>NEW: consume(v2)
end
rect rgb(50, 40, 38)
note over P,CB: Phase 4 — Cleanup
note over OLD: Subject removed
end
This is the distributed equivalent of a database migration with zero downtime.
[Parallel Change (Expand and Contract)] — Martin Fowler , 2014-05-13Subject Resolution Service
Centralize subject names behind a resolution service so consumers don’t hardcode strings:
public interface INatsSubjectResolver
{
string Resolve(string logicalName);
}
public class NatsSubjectResolver : INatsSubjectResolver
{
private readonly Dictionary<string, string> _mappings;
public NatsSubjectResolver(IConfiguration config)
{
_mappings = config.GetSection("Nats:SubjectMappings")
.Get<Dictionary<string, string>>() ?? new();
}
public string Resolve(string logicalName)
{
return _mappings.TryGetValue(logicalName, out var mapped)
? mapped
: logicalName; // Fallback to logical name
}
} Configuration drives the mapping:
{
"Nats": {
"SubjectMappings": {
"ocr.completed": "archives.documents.ocr.completed",
"analysis.completed": "archives.documents.analysis.completed"
}
}
}
[NATS Subject-Based Messaging]
— NATS Authors , 2024-03-20
Dual-Publish During Transition
During phase 1, the producer writes to both old and new subjects:
public class DualPublishEventPublisher : IEventPublisher
{
private readonly INatsConnection _nats;
private readonly INatsSubjectResolver _resolver;
private readonly TransitionConfig _config;
public async Task PublishAsync<T>(
string logicalSubject, T payload, CancellationToken ct)
{
var newSubject = _resolver.Resolve(logicalSubject);
// Always publish to new subject
await _nats.PublishAsync(newSubject, payload, ct);
// During transition, also publish to legacy subject
if (_config.IsInTransition(logicalSubject))
{
await _nats.PublishAsync(logicalSubject, payload, ct);
}
}
} Contract Testing
Add contract tests that verify both producer and consumer agree on the payload shape:
public class EventContractTests
{
[Fact]
public void OcrCompleted_contract_matches()
{
var payload = new OcrCompletedEvent
{
DocumentId = "doc-123",
ObjectKey = "uploads/file.pdf",
ExtractedText = "Sample text",
PageCount = 5,
CompletedAt = DateTime.UtcNow,
};
var json = JsonSerializer.Serialize(payload);
var deserialized = JsonSerializer
.Deserialize<OcrCompletedEvent>(json);
Assert.NotNull(deserialized);
Assert.Equal(payload.DocumentId, deserialized.DocumentId);
Assert.Equal(payload.PageCount, deserialized.PageCount);
}
[Fact]
public void OcrCompleted_tolerates_extra_fields()
{
// Simulates a producer adding a new field
var json = @"{
""DocumentId"": ""doc-123"",
""ObjectKey"": ""uploads/file.pdf"",
""ExtractedText"": ""text"",
""PageCount"": 5,
""NewField"": ""ignored"",
""CompletedAt"": ""2025-01-01T00:00:00Z""
}";
var deserialized = JsonSerializer
.Deserialize<OcrCompletedEvent>(json);
Assert.NotNull(deserialized);
Assert.Equal("doc-123", deserialized.DocumentId);
}
} Payload Evolution Rules
To maintain backward compatibility during transitions:
- Additive only: New fields are optional with defaults
- Never rename: Add a new field, deprecate the old one
- Tolerant reader: Consumers ignore unknown fields
- Envelope pattern: Wrap payloads with metadata (version, timestamp, source)
public record EventEnvelope<T>(
string Version,
string Source,
DateTime Timestamp,
T Payload);
[Schema Evolution and Compatibility]
— Confluent , 2024-06-01
Migration Checklist
- Add new subject to resolver — consumers reference logical names, not raw strings
- Enable dual-publish — producer writes to both old and new subjects
- Migrate consumers one by one — update subscriptions to new subject
- Verify all consumers migrated — check NATS consumer info for old subject
- Disable dual-publish — stop writing to old subject
- Remove old subject config — clean up resolver mappings
Key Takeaways
- Never do hard subject renames — use a transition window with dual publishing
- Centralize subject names behind a resolver so changes are configuration-driven
- Write contract tests that verify payload compatibility bidirectionally
- Follow additive-only schema evolution to maintain backward compatibility
Reflecting on the migrations I have run over the past year, the most important lesson is that silent failures are the real enemy in event-driven systems. A broken REST endpoint gives you a 500 error immediately; a broken event contract gives you silence. Building the subject resolver, transition window pattern, and contract tests into our standard workflow transformed what used to be a high-risk operation into a routine, predictable process.
Next Steps
- Integrate contract tests into your CI/CD pipeline so payload incompatibilities are caught before deployment
- Build a migration dashboard that tracks active consumers on old vs new subjects during transition windows
- Explore NATS subject mapping rules as a server-side alternative to application-level dual publishing
- Add automated phase progression that advances the migration state when old-subject consumer counts reach zero