Skip to main content

Your first service

In the Core NATS chapter you built an inventory responder. It subscribed to orders.inventory.check, read each request, and published an answer back. It worked, but it was anonymous: nothing on the network knew its name, its version, or which subjects it served.

This page promotes that responder into a service: the same answering behavior, now wrapped by the micro framework so it gains a name, a version, and a built-in identity. The bytes on the wire stay the same. The responder becomes a first-class thing the network can name and, on later pages, discover, observe, and scale.

You need a local nats-server running and you should already be comfortable with request-reply. This page covers the two things the framework adds on top of how a reply finds its way back, rather than re-teaching that.

A service is a named responder

A service is a request-reply responder that the framework formalizes. You create one by calling AddService with three values: a Name, a Version, and a Description. The framework takes those, opens the necessary subscriptions, and hands you back a handle to manage it.

The Name is shared by every copy of this service you ever run. Here it is OrderInventory. The Version is a SemVer string, 1.0.0 for our first cut. The Description is free text for humans.

When you create the service, the framework also generates a unique service ID for this one running copy, an auto-generated NUID like IcpremQQGTYS0fK1iyfQ86. You never set it. The service name identifies the kind of service; the service ID identifies this one instance of it. That distinction is what later lets you run five copies of OrderInventory and still talk to exactly one of them.

A service on its own answers nothing yet. It needs an endpoint: a named handler bound to a subject. This is where the inventory logic lives. Add one endpoint, check, on the subject orders.inventory.check (the same subject the Core NATS responder used) and give it the handler that answers the in-stock question.

#!/bin/bash
# Run the OrderInventory service. In a client library you call
# AddService(Name, Version, Description) and then AddEndpoint to bind a
# handler to a subject. From the CLI, `nats service serve` stands up a
# service for you: it registers a name and version, subscribes to the
# discovery verbs under $SRV, and answers requests on an endpoint.
#
# The endpoint joins the default queue group "q" automatically, so
# running more than one copy load-balances requests across them.
#
# Leave this running in its own terminal. It is now OrderInventory, a
# named service the network can discover, not an anonymous responder.
nats service serve OrderInventory

Two details matter. First, the endpoint joins the framework's default queue group, "q", automatically. That's the same queue group mechanism from Core NATS: if you run more than one copy of this service, each request goes to exactly one of them. You get load balancing for free, and the scaling page builds on it.

Second, creating the service quietly subscribes it to a set of discovery subjects under $SRV: the verbs that let callers find it and read its stats. You don't see them here; the discovery page is where they are used. For now, know that they exist the moment the service starts.

The full set of service configuration fields and their valid ranges is documented in Reference. We only need the behavior here.

The handler contract

Inside an endpoint, the handler is the function that processes a request. Its contract is the request-reply you already know, with the framework managing the connection. Each request hands you a request object. You read the incoming bytes with Data() and you send the answer with Respond().

That covers the whole shape, Data() to read and Respond() to answer. The framework took care of subscribing on orders.inventory.check, joining the queue group, reading the reply subject off the incoming message, and publishing your answer to it. The handler doesn't touch a reply subject or an inbox; the framework does, the same work the raw responder performed manually.

With the service running, a caller asks the same question as before. The order-svc client sends the canonical order payload to the check endpoint and waits for one reply:

#!/bin/bash
# Ask the OrderInventory service whether an order's item is in stock.
# `nats service request` looks the service up by name, resolves the
# endpoint's subject from its discovery info, sends the order payload,
# and prints the reply.
#
# The arguments are the service name, the endpoint name, and the payload.
# Underneath it is plain request-reply: a private reply subject, the
# request out on the endpoint's subject, and one answer back.
nats service request OrderInventory check \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'

The request travels to orders.inventory.check, the framework routes it into the check endpoint's queue subscription, your handler runs, and the response flows back to the caller's reply subject. Here's the round trip with the framework wrapper in place:

The orange arrow is the request out on orders.inventory.check. The green arrow is the answer coming back. Underneath, it's plain request-reply over a queue group; the framework gave the responder a name and an identity.

Pitfalls

Two mistakes are common when building your first service. Both are about validation: the framework names your responder and sets up its subscriptions, but it doesn't validate the data that flows through it.

Validate the request body; never let bad input crash the handler. The framework hands your handler whatever bytes the caller sent. If your handler blindly parses them and the caller sent garbage, the parse fails and, depending on the language, the handler can panic, return nothing, or leave the caller waiting out a timeout. Don't assume the payload is well-formed. Parse it, and on failure respond with a service error instead: a response that carries Nats-Service-Error and a Nats-Service-Error-Code header whose value is always safe to parse as a number (use 400 for bad input). The caller then sees a clear "bad request" rather than a hang, and the service stays up for the next caller.

Send a deliberately malformed body and watch the service answer with the error rather than fall over:

#!/bin/bash
# Pitfall demo: send a malformed body to the OrderInventory service.
# A handler that validates its input parses req.Data() and, on failure,
# responds with a service error carrying the Nats-Service-Error and
# Nats-Service-Error-Code (400) headers instead of crashing or hanging.
#
# `--raw` prints the reply body untouched. The caller still gets a clear
# answer back, the error headers tell it the request was bad, and the
# service stays up for the next caller.
nats service request OrderInventory check 'not-json' --raw

The full mechanics of reading that error code back live on the observability page; here the point is only that the handler must produce it.

A service Name and Version are validated at creation. The Name must match the framework's character rule (letters, digits, hyphens, underscores; no dots or spaces), and the Version must be valid SemVer. A Name of Order Inventory (with a space) or a Version of v1 (not SemVer) fails the AddService call outright; the service never starts. Don't discover this in production: pick a valid Name and a real SemVer Version (1.0.0) the first time. The same goes for any Metadata you attach. It's immutable once set, so there's no editing it after creation; you stop the service and start a new one. The full character rules are in Reference.

Where you are

The Acme world now has its first real service:

  • OrderInventory, version 1.0.0, with a unique service ID assigned by the framework.
  • One endpoint, check, answering on orders.inventory.check in the default queue group "q".
  • The same in-stock behavior as the Core NATS responder, now named and wrapped, plus discovery subscriptions running quietly under $SRV.

Nothing about the payload or the subject changed; the responder became a service.

What's next

One service with one endpoint is the smallest useful shape. Real services expose several endpoints and organize them under subject prefixes called groups. The next page adds a second endpoint and a second service.

Continue to Endpoints and groups.

See also