John Weldon

Designing NATS Subject Schemas

Subject design is the first architectural decision in any NATS deployment. It shapes how services communicate, how permissions are enforced, and how the system evolves. A poorly designed subject schema creates operational friction that compounds over time.

Most teams get subjects wrong in predictable ways. This is a distillation of patterns I’ve seen work across enterprise deployments.

The Fundamentals

NATS subjects are dot-separated tokens forming a hierarchical namespace. Subscriptions match against this hierarchy using two wildcards: * (exactly one token) and > (one or more tokens, must be final).

orders.customer.created
|      |        +-- action
|      +-- entity
+-- namespace

A few constraints worth internalizing:

Three Patterns

Most subject schemas follow one of three patterns.

Namespace First

{namespace}.{entity}.{action}

orders.customer.created
payments.transaction.completed
inventory.warehouse.restocked

Best for service-oriented architectures where teams own domains. Subscribe to orders.> to get all order activity. Grant permissions on payments.> to the payments team.

Identifier First

{identifier}.{namespace}.{action}

customer-123.orders.created
tenant-acme.events.user.registered
device-xyz.telemetry.temperature

Best for per-entity subscriptions and multi-tenant isolation. Each entity’s messages flow through a dedicated subject prefix. Subscribe to customer-123.> to follow one customer’s activity.

Multi-Dimensional

{env}.{region}.{service}.{entity}.{action}

prod.us-east.orders.customer.created
staging.eu-west.inventory.product.reserved

Best for complex routing across environments and regions. Use when you need to filter or grant access based on environment, geography, or other cross-cutting concerns.

Choosing

Primary access pattern Use
Service-oriented (all orders, all payments) Namespace first
Entity-oriented (everything for customer X) Identifier first
Multi-tenant isolation Tenant as first token
Cross-environment routing Multi-dimensional

Real systems often combine patterns: {tenant}.{service}.{entity}.{action} for tenant data, platform.{service}.{action} for shared infrastructure.

Reserved Prefixes

These are NATS internals. Don’t publish application messages to them:

Prefix Purpose
$SYS. System monitoring and events
$JS. JetStream API
$KV. Key-Value store internals
$OBJ. Object store internals
_INBOX. Reply subjects for request-reply

The $SYS.> subjects are valuable for monitoring – subscribe to them, just don’t publish to them.

Performance Implications

Wildcard vs. Individual Subscriptions

A service interested in all order events should subscribe to orders.>, not maintain 50 individual subscriptions. But a service that only needs two specific subjects should subscribe to those specifically – individual subscriptions get direct cache hits without traversal.

Scenario Better choice
Large, dynamic subject space Wildcards
Small, fixed interest set Individual subscriptions
Sparse interest (5 of 1,000 subjects) Individual subscriptions

High Cardinality

Systems with millions of unique subjects need care:

The most common mistake: embedding correlation IDs or request IDs in subjects. Use headers instead.

# Avoid
responses.req-uuid-12345-67890

# Prefer
responses.service-a    # correlation ID in Nats-Msg-Id header

JetStream Integration

Stream subject filters determine which messages a stream captures. This interacts with subject design in non-obvious ways.

A stream filtering on orders.> captures everything in the orders namespace. If you later need separate streams for orders and order-auditing, you’ll need to split the subject space. Planning for this from day one is cheaper than migrating later.

Consumer subject filters narrow delivery further. A consumer filtering on orders.*.created within an orders.> stream receives only creation events. This works well with namespace-first hierarchies where the action token is in a predictable position.

Security Model

NATS permissions operate on subject patterns. Subject hierarchy directly determines your security boundaries:

# Grant full access to the orders namespace
permissions {
    publish = ["orders.>"]
    subscribe = ["orders.>"]
}

# Read-only access to order events
permissions {
    subscribe = ["orders.>"]
}

With namespace-first design, permissions align naturally with team ownership. With identifier-first design, permissions align with entity access. Choose the hierarchy that matches your authorization model.

Common Mistakes

Flat subjects. Using orderCreated, orderUpdated, paymentCompleted as subjects gives up all hierarchy benefits – no wildcards, no permission grouping, no namespace isolation.

Too many tokens. Encoding every possible dimension into the subject (prod.us-east-1.az-a.team-payments.service-gateway.orders.customer.v2.created) creates long subjects that are hard to read, hard to match, and push toward the 32-token heap allocation threshold.

Missing version tokens. Starting with orders.created and later needing a breaking change means migrating every subscriber. Starting with orders.v1.created provides a clean evolution path. Add the version token from day one.

Encoding payload data in subjects. Subjects are for routing. Data belongs in the message payload or headers. If your subject contains a JSON field value, you’re probably doing it wrong.

Evolving a Schema

Subject schemas evolve. Plan for it:

  1. Version tokens provide clean migration paths
  2. Subject mappings (server-side transforms) can bridge old and new schemas during migration
  3. Stream subject filters determine what JetStream captures – changing them requires a new stream or consumer
  4. Permissions may need updating when subjects change – coordinate with your auth model

The cost of a subject schema change increases with the number of publishers and subscribers that reference it. Getting the design right early – especially the first two tokens – saves significant operational work later.


  1. The tsa := [32]string{} stack-allocated array appears in nats-server server/sublist.go at multiple call sites. Subjects exceeding 32 tokens cause the token slice to escape to the heap. ↩︎

  2. Constants slCacheMax = 1024 and slCacheSweep = 512 are defined in nats-server server/sublist.go. The cache accelerates message routing by avoiding subscription tree traversal for frequently-used subjects. ↩︎

  3. The constant JSMaxSubjectDetails = 100_000 is defined in nats-server server/jetstream_api.go. Results beyond this limit require pagination via offset. ↩︎