CasesBlog
Nikita Leino Dec 4, 2025 GoNatsLibrary

How We Tamed NATS in Go: Introducing deez-nats — Our Answer to Microservice Complexity

From Developers, for Developers: Why we got tired of boilerplate and built our own framework.

Hello everyone! We are the development team behind deez-nats.

In our architecture, we rely heavily on NATS. It's a fantastic technology: fast, reliable, and supports both simple Pub/Sub (Core) and persistent streams (JetStream). But as our microservices scaled, we ran into a common problem for Go developers: boilerplate.

We found ourselves spending 30% of our time on business logic and 70% on NATS "plumbing." Endless nc.Subscribe calls, manual JSON marshaling, error handling, managing timeouts in RPC calls... Our code became bloated, and maintainability suffered.

We needed a tool that provided the convenience of modern HTTP frameworks (like Gin or Echo), but designed for asynchronous messaging. Since we couldn't find the perfect solution, we built our own.

Meet deez-nats—a library that transforms working with NATS into a genuinely pleasant experience.


The deez-nats Philosophy

When we designed this library, we focused on three core principles:

  1. Developer Experience (DX): The API must be intuitive and feel like a modern router.

  2. Type Safety: We love Go for its typing, so the library makes extensive use of Generics to prevent runtime errors.

  3. Production Ready: Graceful shutdown, middleware, and context propagation are essential, not optional.

Let’s see how we achieved this in practice.

Installation and Basic Setup

The setup is standard. The library requires Go 1.21+ to leverage modern language features.

go get github.com/leinodev/deez-nats

In your entry point (main.go), you initialize two primary services: one for RPC (synchronous request-reply) and one for events (Pub/Sub).

package main

import (
    "context"
    "log"
    "github.com/nats-io/nats.go"
    "github.com/leinodev/deez-nats/natsevents"
    "github.com/leinodev/deez-nats/natsrpc"
)

func main() {
    // Connect to the NATS server
    nc, _ := nats.Connect(nats.DefaultURL)
    defer nc.Close()

    // Initialize our RPC and Event wrappers
    // We can pass configuration options here (e.g., base route)
    rpcSvc := natsrpc.New(nc, natsrpc.WithBaseRoute("my-service")) 
    eventsSvc := natsevents.New(nc)

    // ... handler registration goes here ...

    ctx := context.Background()
    // Start listening in separate goroutines
    go rpcSvc.StartWithContext(ctx)
    go eventsSvc.StartWithContext(ctx)

    select {} // Block the main thread
}

RPC: Forget Manual Publishing

In pure NATS, implementing the Request-Reply pattern requires manually handling the Reply subject. We automated this entirely through the RPCContext abstraction.

Here is what a typical, production-grade RPC handler looks like:

type UserRequest struct {
    ID string `json:"id"`
}

type UserResponse struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// Registering a strictly typed handler
rpcSvc.AddRPCHandler("users.get", func(ctx natsrpc.RPCContext) error {
    var req UserRequest
    
    // Request() automatically parses JSON and returns an error on malformed payload
    if err := ctx.Request(&req); err != nil {
        return err // The error will be sent back to the caller
    }

    // Emulate business logic
    user, found := database.FindUser(req.ID)
    if !found {
        return fmt.Errorf("user not found")
    }

    // Send the response. The library automatically marshals the struct to JSON.
    return ctx.Ok(UserResponse{
        Name:  user.Name,
        Email: user.Email,
    })
})

Calling the method from a client is just as clean:

var response UserResponse
// CallRPC abstracts timeouts, serialization, and deserialization
err := rpcSvc.CallRPC(ctx, "users.get", UserRequest{ID: "101"}, &response)

No manual subscriptions, no []byte gymnastics. Just clean, typed function calls.

Middleware and Grouping: Structure Your Services

When a project grows, dumping all routes into a single list is unsustainable. We introduced the concept of Groups and Middleware, drawing inspiration from battle-tested HTTP routers.

This allows us to add logging, authentication checks, or metrics only to a specific set of commands.

// Create a route group for admin commands
adminGroup := rpcSvc.Group("admin")

// Add a Middleware that applies to ALL handlers in this group
adminGroup.Use(func(next natsrpc.RPCHandler) natsrpc.RPCHandler {
    return func(ctx natsrpc.RPCContext) error {
        log.Println("--- ACCESSING ADMIN AREA ---")
        // Authentication or logging logic goes here
        return next(ctx)
    }
})

// Register the handler. The final subject will be "my-service.admin.delete-user"
adminGroup.AddRPCHandler("delete-user", deleteUserHandler)

JetStream and Typed Events: Scaling Asynchronously

Working with JetStream (JS) is inherently more complex than Core NATS due to consumer management and Ack policies. Our goal was to hide this complexity while preserving flexibility.

Again, we use generics for strict event typing.

type OrderCreated struct {
    OrderID string  `json:"order_id"`
    Amount  float64 `json:"amount"`
}

// Subscribe to a JetStream event
natsevents.AddTypedJetStreamJsonEventHandler(
    eventsSvc,
    "orders.created",
    func(ctx natsevents.EventContext[jetstream.Msg, any], event OrderCreated) error {
        
        log.Printf("Processing order: %s for amount %.2f", event.OrderID, event.Amount)
        
        // Killer feature: If the function returns nil, 
        // the library automatically sends a successful Ack for the message.
        return nil
    },
    // Optional: Specify Durable Consumer and Queue Group for scaling
    natsevents.WithJetStreamHandlerQueue("order-processor-queue"),
)

The Power of Error Handling: The library automatically manages the Ack status. If your handler returns an error (return err), a Nak (negative acknowledgment) is sent, and the message will be redelivered according to your JetStream policies.

Graceful Shutdown: The Professional Way to Exit

In a containerized environment, pods are constantly being terminated and restarted. You must allow active handlers to complete their work before killing the process.

deez-nats implements the correct graceful shutdown pattern. The Shutdown method:

  1. Stops accepting new messages (drains subscriptions).

  2. Waits for all active handlers to finish processing.

  3. Closes subscriptions and the underlying NATS connection.

// In main.go
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan

log.Println("Received shutdown signal. Starting graceful sequence...")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// Safely shut down all services
if err := rpcSvc.Shutdown(shutdownCtx); err != nil {
    log.Printf("Error shutting down RPC service: %v", err)
}
if err := eventsSvc.Shutdown(shutdownCtx); err != nil {
    log.Printf("Error shutting down Events service: %v", err)
}

log.Println("Application gracefully stopped.")

Conclusion

We built deez-nats not as a hobby project, but as a critical tool to solve real-world complexities in our production environment. It has significantly reduced boilerplate, minimized type-related errors, and standardized the way our services communicate.

The library supports custom marshallers (want Protobuf instead of JSON? Go for it!) and remains fully compatible with the native nats.go client.

We encourage you to integrate it into your next Go project: GitHub: leinodev/deez-nats

We welcome your stars, issues, and pull requests. Let's make the Go ecosystem better together! 🥜

Want to use a similar technology?

Our team develops web applications, bots, video services, and AI integrations from scratch.