Building a real-time note-sharing application is a perfect use case for exploring event-driven architecture. In this comprehensive guide, we’ll build a production-ready system with two microservices (User and Note services) that communicate through NATS, delivering instant updates to a Vue.js frontend.

Why Event-Driven Architecture?

Traditional request-response patterns create tight coupling between services. When your Note service needs to notify users about changes, you don’t want to make synchronous HTTP calls to every service that cares about notes. Event-driven architecture solves this with loose coupling:

  • Decoupled Services: Services don’t need to know about each other
  • Scalability: Add new subscribers without modifying publishers
  • Resilience: Service failures don’t cascade
  • Real-time: Events propagate instantly to interested parties
  • Audit Trail: Event streams provide built-in history

System Architecture

We’ll build three components:

  1. User Service: Handles authentication (register/login) and publishes user events
  2. Note Service: Manages CRUD operations on notes and publishes note events
  3. Frontend Gateway: Bridges NATS events to WebSocket for browser clients
%%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "Frontend" VueApp[Vue.js App] end subgraph "API Gateway" WSGateway[WebSocket Gateway] end subgraph "Microservices" UserSvc[User Service
:8081] NoteSvc[Note Service
:8082] end subgraph "Infrastructure" NATS[NATS Server
:4222] DB1[(User DB)] DB2[(Note DB)] end VueApp <-->|WebSocket| WSGateway VueApp -->|HTTP| UserSvc VueApp -->|HTTP| NoteSvc UserSvc <-->|Pub/Sub| NATS NoteSvc <-->|Pub/Sub| NATS WSGateway <-->|Subscribe| NATS UserSvc --> DB1 NoteSvc --> DB2

Event Flow Diagram

%%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% sequenceDiagram participant Client as Vue.js Client participant UserSvc as User Service participant NoteSvc as Note Service participant NATS as NATS Server participant WSGateway as WS Gateway rect rgb(40, 60, 80) note over Client,WSGateway: Authentication Flow Client->>UserSvc: POST /register UserSvc->>UserSvc: Hash password UserSvc->>NATS: Publish user.created UserSvc-->>Client: Return JWT token WSGateway->>NATS: Subscribe user.* NATS-->>WSGateway: user.created event WSGateway-->>Client: Real-time notification end rect rgb(60, 40, 80) note over Client,WSGateway: Note Operations Flow Client->>NoteSvc: POST /notes (with JWT) NoteSvc->>NoteSvc: Validate & create note NoteSvc->>NATS: Publish note.created NoteSvc-->>Client: Return note WSGateway->>NATS: Subscribe note.* NATS-->>WSGateway: note.created event WSGateway-->>Client: Broadcast to all users end rect rgb(40, 80, 60) note over Client,WSGateway: Real-time Update Flow Client->>NoteSvc: PUT /notes/:id NoteSvc->>NATS: Publish note.updated NoteSvc-->>Client: Return updated note NATS-->>WSGateway: note.updated event WSGateway-->>Client: Push update end

Project Structure

event-driven-notes/
├── services/
│   ├── user-service/
│   │   ├── main.go
│   │   ├── handlers.go
│   │   ├── models.go
│   │   └── events.go
│   ├── note-service/
│   │   ├── main.go
│   │   ├── handlers.go
│   │   ├── models.go
│   │   └── events.go
│   └── gateway/
│       ├── main.go
│       └── websocket.go
├── frontend/
│   └── src/
│       ├── App.vue
│       └── services/
│           ├── api.js
│           └── websocket.js
├── docker-compose.yml
└── go.mod

Part 1: User Service with NATS Events

The User service handles authentication and publishes events when users are created or logged in.

User Service - models.go

package main

import (
    "time"
    "golang.org/x/crypto/bcrypt"
)

type User struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Username  string    `json:"username"`
    Password  string    `json:"-"` // Never return in JSON
    CreatedAt time.Time `json:"created_at"`
}

type RegisterRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Username string `json:"username" binding:"required,min=3"`
    Password string `json:"password" binding:"required,min=6"`
}

type LoginRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required"`
}

type AuthResponse struct {
    Token string `json:"token"`
    User  User   `json:"user"`
}

// UserEvent represents events published to NATS
type UserEvent struct {
    Type      string    `json:"type"` // "created", "logged_in"
    UserID    string    `json:"user_id"`
    Username  string    `json:"username"`
    Email     string    `json:"email"`
    Timestamp time.Time `json:"timestamp"`
}

func (u *User) HashPassword(password string) error {
    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    u.Password = string(hash)
    return nil
}

func (u *User) CheckPassword(password string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
    return err == nil
}

User Service - main.go

package main

import (
    "encoding/json"
    "log"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
    "github.com/nats-io/nats.go"
)

var (
    users      = make(map[string]*User) // In-memory store (use real DB in production)
    usersMutex sync.RWMutex
    jwtSecret  = []byte("your-secret-key-change-in-production")
    nc         *nats.Conn
)

func main() {
    // Connect to NATS
    var err error
    nc, err = nats.Connect(nats.DefaultURL)
    if err != nil {
        log.Fatal("Failed to connect to NATS:", err)
    }
    defer nc.Close()
    log.Println("Connected to NATS")

    r := gin.Default()
    r.Use(corsMiddleware())

    r.POST("/register", registerHandler)
    r.POST("/login", loginHandler)
    r.GET("/users/:id", authMiddleware(), getUserHandler)

    log.Println("User Service running on :8081")
    r.Run(":8081")
}

func registerHandler(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // Check if user exists
    usersMutex.RLock()
    for _, u := range users {
        if u.Email == req.Email {
            usersMutex.RUnlock()
            c.JSON(409, gin.H{"error": "User already exists"})
            return
        }
    }
    usersMutex.RUnlock()

    // Create user
    user := &User{
        ID:        uuid.New().String(),
        Email:     req.Email,
        Username:  req.Username,
        CreatedAt: time.Now(),
    }

    if err := user.HashPassword(req.Password); err != nil {
        c.JSON(500, gin.H{"error": "Failed to hash password"})
        return
    }

    usersMutex.Lock()
    users[user.ID] = user
    usersMutex.Unlock()

    // Publish event to NATS
    event := UserEvent{
        Type:      "created",
        UserID:    user.ID,
        Username:  user.Username,
        Email:     user.Email,
        Timestamp: time.Now(),
    }
    publishUserEvent("user.created", event)

    // Generate JWT
    token, err := generateJWT(user.ID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate token"})
        return
    }

    c.JSON(201, AuthResponse{
        Token: token,
        User:  *user,
    })
}

func loginHandler(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // Find user
    usersMutex.RLock()
    var foundUser *User
    for _, u := range users {
        if u.Email == req.Email {
            foundUser = u
            break
        }
    }
    usersMutex.RUnlock()

    if foundUser == nil || !foundUser.CheckPassword(req.Password) {
        c.JSON(401, gin.H{"error": "Invalid credentials"})
        return
    }

    // Publish login event
    event := UserEvent{
        Type:      "logged_in",
        UserID:    foundUser.ID,
        Username:  foundUser.Username,
        Email:     foundUser.Email,
        Timestamp: time.Now(),
    }
    publishUserEvent("user.logged_in", event)

    token, err := generateJWT(foundUser.ID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate token"})
        return
    }

    c.JSON(200, AuthResponse{
        Token: token,
        User:  *foundUser,
    })
}

func getUserHandler(c *gin.Context) {
    userID := c.Param("id")

    usersMutex.RLock()
    user, exists := users[userID]
    usersMutex.RUnlock()

    if !exists {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    c.JSON(200, user)
}

func publishUserEvent(subject string, event UserEvent) {
    data, err := json.Marshal(event)
    if err != nil {
        log.Printf("Failed to marshal event: %v", err)
        return
    }

    if err := nc.Publish(subject, data); err != nil {
        log.Printf("Failed to publish event: %v", err)
        return
    }

    log.Printf("Published event: %s -> %+v", subject, event)
}

func generateJWT(userID string) (string, error) {
    claims := jwt.MapClaims{
        "user_id": userID,
        "exp":     time.Now().Add(24 * time.Hour).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

func authMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(401, gin.H{"error": "No authorization header"})
            return
        }

        // Remove "Bearer " prefix
        tokenString := authHeader[7:]

        token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
            return jwtSecret, nil
        })

        if err != nil || !token.Valid {
            c.AbortWithStatusJSON(401, gin.H{"error": "Invalid token"})
            return
        }

        claims := token.Claims.(jwt.MapClaims)
        c.Set("user_id", claims["user_id"])
        c.Next()
    }
}

func corsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }
        c.Next()
    }
}

Part 2: Note Service with Event Publishing

The Note service manages notes and publishes events for all CRUD operations.

Note Service - models.go

package main

import "time"

type Note struct {
    ID        string    `json:"id"`
    UserID    string    `json:"user_id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    Tags      []string  `json:"tags"`
    IsPublic  bool      `json:"is_public"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type CreateNoteRequest struct {
    Title    string   `json:"title" binding:"required"`
    Content  string   `json:"content" binding:"required"`
    Tags     []string `json:"tags"`
    IsPublic bool     `json:"is_public"`
}

type UpdateNoteRequest struct {
    Title    *string   `json:"title"`
    Content  *string   `json:"content"`
    Tags     *[]string `json:"tags"`
    IsPublic *bool     `json:"is_public"`
}

// NoteEvent represents events published to NATS
type NoteEvent struct {
    Type      string    `json:"type"` // "created", "updated", "deleted", "shared"
    NoteID    string    `json:"note_id"`
    UserID    string    `json:"user_id"`
    Title     string    `json:"title"`
    IsPublic  bool      `json:"is_public"`
    Timestamp time.Time `json:"timestamp"`
}

Note Service - main.go

package main

import (
    "encoding/json"
    "log"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
    "github.com/nats-io/nats.go"
)

var (
    notes      = make(map[string]*Note)
    notesMutex sync.RWMutex
    jwtSecret  = []byte("your-secret-key-change-in-production")
    nc         *nats.Conn
)

func main() {
    var err error
    nc, err = nats.Connect(nats.DefaultURL)
    if err != nil {
        log.Fatal("Failed to connect to NATS:", err)
    }
    defer nc.Close()
    log.Println("Connected to NATS")

    // Subscribe to user events
    subscribeToUserEvents()

    r := gin.Default()
    r.Use(corsMiddleware())

    // Public endpoints
    r.GET("/notes/public", getPublicNotesHandler)

    // Protected endpoints
    authorized := r.Group("/")
    authorized.Use(authMiddleware())
    {
        authorized.POST("/notes", createNoteHandler)
        authorized.GET("/notes", getMyNotesHandler)
        authorized.GET("/notes/:id", getNoteHandler)
        authorized.PUT("/notes/:id", updateNoteHandler)
        authorized.DELETE("/notes/:id", deleteNoteHandler)
        authorized.POST("/notes/:id/share", shareNoteHandler)
    }

    log.Println("Note Service running on :8082")
    r.Run(":8082")
}

func subscribeToUserEvents() {
    // Subscribe to all user events
    _, err := nc.Subscribe("user.*", func(msg *nats.Msg) {
        var event map[string]interface{}
        if err := json.Unmarshal(msg.Data, &event); err != nil {
            log.Printf("Failed to parse user event: %v", err)
            return
        }
        log.Printf("Received user event: %s -> %+v", msg.Subject, event)
        // Here you could implement logic like:
        // - Track user activity
        // - Send welcome notes to new users
        // - Clean up notes when users are deleted
    })

    if err != nil {
        log.Printf("Failed to subscribe to user events: %v", err)
    }
}

func createNoteHandler(c *gin.Context) {
    userID := c.GetString("user_id")

    var req CreateNoteRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    note := &Note{
        ID:        uuid.New().String(),
        UserID:    userID,
        Title:     req.Title,
        Content:   req.Content,
        Tags:      req.Tags,
        IsPublic:  req.IsPublic,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    notesMutex.Lock()
    notes[note.ID] = note
    notesMutex.Unlock()

    // Publish event
    publishNoteEvent("note.created", NoteEvent{
        Type:      "created",
        NoteID:    note.ID,
        UserID:    note.UserID,
        Title:     note.Title,
        IsPublic:  note.IsPublic,
        Timestamp: time.Now(),
    })

    c.JSON(201, note)
}

func getMyNotesHandler(c *gin.Context) {
    userID := c.GetString("user_id")

    notesMutex.RLock()
    defer notesMutex.RUnlock()

    var userNotes []*Note
    for _, note := range notes {
        if note.UserID == userID {
            userNotes = append(userNotes, note)
        }
    }

    c.JSON(200, userNotes)
}

func getPublicNotesHandler(c *gin.Context) {
    notesMutex.RLock()
    defer notesMutex.RUnlock()

    var publicNotes []*Note
    for _, note := range notes {
        if note.IsPublic {
            publicNotes = append(publicNotes, note)
        }
    }

    c.JSON(200, publicNotes)
}

func getNoteHandler(c *gin.Context) {
    noteID := c.Param("id")
    userID := c.GetString("user_id")

    notesMutex.RLock()
    note, exists := notes[noteID]
    notesMutex.RUnlock()

    if !exists {
        c.JSON(404, gin.H{"error": "Note not found"})
        return
    }

    // Check access rights
    if note.UserID != userID && !note.IsPublic {
        c.JSON(403, gin.H{"error": "Access denied"})
        return
    }

    c.JSON(200, note)
}

func updateNoteHandler(c *gin.Context) {
    noteID := c.Param("id")
    userID := c.GetString("user_id")

    var req UpdateNoteRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    notesMutex.Lock()
    defer notesMutex.Unlock()

    note, exists := notes[noteID]
    if !exists {
        c.JSON(404, gin.H{"error": "Note not found"})
        return
    }

    if note.UserID != userID {
        c.JSON(403, gin.H{"error": "Access denied"})
        return
    }

    // Update fields
    if req.Title != nil {
        note.Title = *req.Title
    }
    if req.Content != nil {
        note.Content = *req.Content
    }
    if req.Tags != nil {
        note.Tags = *req.Tags
    }
    if req.IsPublic != nil {
        note.IsPublic = *req.IsPublic
    }
    note.UpdatedAt = time.Now()

    // Publish event
    publishNoteEvent("note.updated", NoteEvent{
        Type:      "updated",
        NoteID:    note.ID,
        UserID:    note.UserID,
        Title:     note.Title,
        IsPublic:  note.IsPublic,
        Timestamp: time.Now(),
    })

    c.JSON(200, note)
}

func deleteNoteHandler(c *gin.Context) {
    noteID := c.Param("id")
    userID := c.GetString("user_id")

    notesMutex.Lock()
    defer notesMutex.Unlock()

    note, exists := notes[noteID]
    if !exists {
        c.JSON(404, gin.H{"error": "Note not found"})
        return
    }

    if note.UserID != userID {
        c.JSON(403, gin.H{"error": "Access denied"})
        return
    }

    delete(notes, noteID)

    // Publish event
    publishNoteEvent("note.deleted", NoteEvent{
        Type:      "deleted",
        NoteID:    noteID,
        UserID:    userID,
        Title:     note.Title,
        IsPublic:  note.IsPublic,
        Timestamp: time.Now(),
    })

    c.JSON(200, gin.H{"message": "Note deleted"})
}

func shareNoteHandler(c *gin.Context) {
    noteID := c.Param("id")
    userID := c.GetString("user_id")

    notesMutex.Lock()
    defer notesMutex.Unlock()

    note, exists := notes[noteID]
    if !exists {
        c.JSON(404, gin.H{"error": "Note not found"})
        return
    }

    if note.UserID != userID {
        c.JSON(403, gin.H{"error": "Access denied"})
        return
    }

    note.IsPublic = true
    note.UpdatedAt = time.Now()

    // Publish share event
    publishNoteEvent("note.shared", NoteEvent{
        Type:      "shared",
        NoteID:    note.ID,
        UserID:    note.UserID,
        Title:     note.Title,
        IsPublic:  true,
        Timestamp: time.Now(),
    })

    c.JSON(200, note)
}

func publishNoteEvent(subject string, event NoteEvent) {
    data, err := json.Marshal(event)
    if err != nil {
        log.Printf("Failed to marshal event: %v", err)
        return
    }

    if err := nc.Publish(subject, data); err != nil {
        log.Printf("Failed to publish event: %v", err)
        return
    }

    log.Printf("Published event: %s -> %+v", subject, event)
}

func authMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(401, gin.H{"error": "No authorization header"})
            return
        }

        tokenString := authHeader[7:] // Remove "Bearer "

        token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
            return jwtSecret, nil
        })

        if err != nil || !token.Valid {
            c.AbortWithStatusJSON(401, gin.H{"error": "Invalid token"})
            return
        }

        claims := token.Claims.(jwt.MapClaims)
        c.Set("user_id", claims["user_id"].(string))
        c.Next()
    }
}

func corsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }
        c.Next()
    }
}

Part 3: WebSocket Gateway for Real-Time Updates

The gateway bridges NATS events to WebSocket connections, enabling real-time browser updates.

Gateway - main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"

    "github.com/gin-gonic/gin"
    "github.com/gorilla/websocket"
    "github.com/nats-io/nats.go"
)

var (
    upgrader = websocket.Upgrader{
        CheckOrigin: func(r *http.Request) bool { return true },
    }
    clients      = make(map[*websocket.Conn]bool)
    clientsMutex sync.RWMutex
    nc           *nats.Conn
)

type BroadcastMessage struct {
    Type    string      `json:"type"`
    Subject string      `json:"subject"`
    Data    interface{} `json:"data"`
}

func main() {
    var err error
    nc, err = nats.Connect(nats.DefaultURL)
    if err != nil {
        log.Fatal("Failed to connect to NATS:", err)
    }
    defer nc.Close()

    // Subscribe to all events
    subscribeToEvents()

    r := gin.Default()
    r.GET("/ws", handleWebSocket)
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "status":  "healthy",
            "clients": len(clients),
        })
    })

    log.Println("WebSocket Gateway running on :8083")
    r.Run(":8083")
}

func subscribeToEvents() {
    // Subscribe to user events
    nc.Subscribe("user.>", func(msg *nats.Msg) {
        var event map[string]interface{}
        if err := json.Unmarshal(msg.Data, &event); err != nil {
            log.Printf("Failed to parse event: %v", err)
            return
        }

        broadcastMsg := BroadcastMessage{
            Type:    "event",
            Subject: msg.Subject,
            Data:    event,
        }
        broadcastToClients(broadcastMsg)
    })

    // Subscribe to note events
    nc.Subscribe("note.>", func(msg *nats.Msg) {
        var event map[string]interface{}
        if err := json.Unmarshal(msg.Data, &event); err != nil {
            log.Printf("Failed to parse event: %v", err)
            return
        }

        broadcastMsg := BroadcastMessage{
            Type:    "event",
            Subject: msg.Subject,
            Data:    event,
        }
        broadcastToClients(broadcastMsg)
    })

    log.Println("Subscribed to NATS events: user.>, note.>")
}

func handleWebSocket(c *gin.Context) {
    conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
    if err != nil {
        log.Printf("WebSocket upgrade failed: %v", err)
        return
    }

    clientsMutex.Lock()
    clients[conn] = true
    clientsMutex.Unlock()

    log.Printf("New WebSocket client connected. Total clients: %d", len(clients))

    // Send welcome message
    welcome := BroadcastMessage{
        Type:    "connected",
        Subject: "system",
        Data: map[string]interface{}{
            "message": "Connected to event stream",
        },
    }
    conn.WriteJSON(welcome)

    // Handle disconnection
    defer func() {
        clientsMutex.Lock()
        delete(clients, conn)
        clientsMutex.Unlock()
        conn.Close()
        log.Printf("Client disconnected. Total clients: %d", len(clients))
    }()

    // Keep connection alive
    for {
        _, _, err := conn.ReadMessage()
        if err != nil {
            break
        }
    }
}

func broadcastToClients(msg BroadcastMessage) {
    clientsMutex.RLock()
    defer clientsMutex.RUnlock()

    data, err := json.Marshal(msg)
    if err != nil {
        log.Printf("Failed to marshal broadcast message: %v", err)
        return
    }

    log.Printf("Broadcasting to %d clients: %s", len(clients), msg.Subject)

    for client := range clients {
        err := client.WriteMessage(websocket.TextMessage, data)
        if err != nil {
            log.Printf("Failed to write to client: %v", err)
        }
    }
}

Part 4: Vue.js Frontend with Real-Time Updates

Frontend Setup

First, create a Vue 3 application:

npm create vue@latest
cd note-app
npm install axios

API Service - services/api.js

import axios from 'axios'

const USER_API = 'http://localhost:8081'
const NOTE_API = 'http://localhost:8082'

class ApiService {
  constructor() {
    this.token = localStorage.getItem('auth_token')
  }

  setToken(token) {
    this.token = token
    localStorage.setItem('auth_token', token)
  }

  getAuthHeaders() {
    return {
      Authorization: `Bearer ${this.token}`
    }
  }

  // User Service
  async register(email, username, password) {
    const response = await axios.post(`${USER_API}/register`, {
      email,
      username,
      password
    })
    this.setToken(response.data.token)
    return response.data
  }

  async login(email, password) {
    const response = await axios.post(`${USER_API}/login`, {
      email,
      password
    })
    this.setToken(response.data.token)
    return response.data
  }

  // Note Service
  async createNote(title, content, tags = [], isPublic = false) {
    const response = await axios.post(
      `${NOTE_API}/notes`,
      { title, content, tags, is_public: isPublic },
      { headers: this.getAuthHeaders() }
    )
    return response.data
  }

  async getMyNotes() {
    const response = await axios.get(`${NOTE_API}/notes`, {
      headers: this.getAuthHeaders()
    })
    return response.data
  }

  async getPublicNotes() {
    const response = await axios.get(`${NOTE_API}/notes/public`)
    return response.data
  }

  async updateNote(id, updates) {
    const response = await axios.put(`${NOTE_API}/notes/${id}`, updates, {
      headers: this.getAuthHeaders()
    })
    return response.data
  }

  async deleteNote(id) {
    await axios.delete(`${NOTE_API}/notes/${id}`, {
      headers: this.getAuthHeaders()
    })
  }

  async shareNote(id) {
    const response = await axios.post(
      `${NOTE_API}/notes/${id}/share`,
      {},
      { headers: this.getAuthHeaders() }
    )
    return response.data
  }
}

export default new ApiService()

WebSocket Service - services/websocket.js

class WebSocketService {
  constructor() {
    this.ws = null
    this.listeners = new Map()
    this.reconnectInterval = 3000
    this.shouldReconnect = true
  }

  connect() {
    this.ws = new WebSocket('ws://localhost:8083/ws')

    this.ws.onopen = () => {
      console.log('WebSocket connected')
    }

    this.ws.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data)
        this.notifyListeners(message)
      } catch (err) {
        console.error('Failed to parse WebSocket message:', err)
      }
    }

    this.ws.onclose = () => {
      console.log('WebSocket disconnected')
      if (this.shouldReconnect) {
        setTimeout(() => this.connect(), this.reconnectInterval)
      }
    }

    this.ws.onerror = (error) => {
      console.error('WebSocket error:', error)
    }
  }

  disconnect() {
    this.shouldReconnect = false
    if (this.ws) {
      this.ws.close()
    }
  }

  // Subscribe to specific event subjects
  on(subject, callback) {
    if (!this.listeners.has(subject)) {
      this.listeners.set(subject, [])
    }
    this.listeners.get(subject).push(callback)
  }

  // Unsubscribe
  off(subject, callback) {
    if (!this.listeners.has(subject)) return

    const callbacks = this.listeners.get(subject)
    const index = callbacks.indexOf(callback)
    if (index > -1) {
      callbacks.splice(index, 1)
    }
  }

  notifyListeners(message) {
    const { subject, data } = message

    // Notify wildcard listeners
    this.listeners.get('*')?.forEach(cb => cb(message))

    // Notify specific subject listeners
    this.listeners.get(subject)?.forEach(cb => cb(data))

    // Notify pattern listeners (e.g., "note.*")
    for (let [pattern, callbacks] of this.listeners) {
      if (this.matchPattern(pattern, subject)) {
        callbacks.forEach(cb => cb(data))
      }
    }
  }

  matchPattern(pattern, subject) {
    if (pattern === '*') return true
    if (pattern === subject) return true

    const patternParts = pattern.split('.')
    const subjectParts = subject.split('.')

    if (patternParts.length !== subjectParts.length) return false

    return patternParts.every((part, i) => {
      return part === '*' || part === '>' || part === subjectParts[i]
    })
  }
}

export default new WebSocketService()

Main App Component - App.vue

<template>
  <div id="app" class="container">
    <header>
      <h1>📝 Real-Time Note Sharing</h1>
      <div v-if="user" class="user-info">
        <span>Welcome, {{ user.username }}!</span>
        <button @click="logout" class="btn-secondary">Logout</button>
      </div>
    </header>

    <!-- Authentication Forms -->
    <div v-if="!user" class="auth-section">
      <div class="auth-form">
        <h2>{{ showLogin ? 'Login' : 'Register' }}</h2>
        <form @submit.prevent="handleAuth">
          <input
            v-model="email"
            type="email"
            placeholder="Email"
            required
          />
          <input
            v-if="!showLogin"
            v-model="username"
            type="text"
            placeholder="Username"
            required
          />
          <input
            v-model="password"
            type="password"
            placeholder="Password"
            required
          />
          <button type="submit" class="btn-primary">
            {{ showLogin ? 'Login' : 'Register' }}
          </button>
        </form>
        <p>
          <a @click="showLogin = !showLogin" class="link">
            {{ showLogin ? 'Need an account? Register' : 'Have an account? Login' }}
          </a>
        </p>
      </div>
    </div>

    <!-- Notes Interface -->
    <div v-else class="notes-section">
      <!-- Create Note Form -->
      <div class="create-note">
        <h2>Create New Note</h2>
        <form @submit.prevent="createNote">
          <input
            v-model="newNote.title"
            type="text"
            placeholder="Note title"
            required
          />
          <textarea
            v-model="newNote.content"
            placeholder="Note content"
            rows="4"
            required
          ></textarea>
          <input
            v-model="newNote.tags"
            type="text"
            placeholder="Tags (comma-separated)"
          />
          <label>
            <input v-model="newNote.isPublic" type="checkbox" />
            Make this note public
          </label>
          <button type="submit" class="btn-primary">Create Note</button>
        </form>
      </div>

      <!-- My Notes -->
      <div class="notes-list">
        <h2>My Notes</h2>
        <div v-if="myNotes.length === 0" class="empty-state">
          No notes yet. Create your first note above!
        </div>
        <div v-for="note in myNotes" :key="note.id" class="note-card">
          <div class="note-header">
            <h3>{{ note.title }}</h3>
            <span :class="['badge', note.is_public ? 'public' : 'private']">
              {{ note.is_public ? '🌐 Public' : '🔒 Private' }}
            </span>
          </div>
          <p>{{ note.content }}</p>
          <div class="note-footer">
            <div class="tags">
              <span v-for="tag in note.tags" :key="tag" class="tag">
                #{{ tag }}
              </span>
            </div>
            <div class="actions">
              <button
                v-if="!note.is_public"
                @click="shareNote(note.id)"
                class="btn-small"
              >
                Share
              </button>
              <button @click="deleteNote(note.id)" class="btn-small btn-danger">
                Delete
              </button>
            </div>
          </div>
        </div>
      </div>

      <!-- Public Notes -->
      <div class="notes-list">
        <h2>Public Notes Feed</h2>
        <div v-if="publicNotes.length === 0" class="empty-state">
          No public notes yet.
        </div>
        <div v-for="note in publicNotes" :key="note.id" class="note-card public">
          <div class="note-header">
            <h3>{{ note.title }}</h3>
            <span class="badge public">🌐 Public</span>
          </div>
          <p>{{ note.content }}</p>
          <div class="note-footer">
            <div class="tags">
              <span v-for="tag in note.tags" :key="tag" class="tag">
                #{{ tag }}
              </span>
            </div>
          </div>
        </div>
      </div>

      <!-- Real-time Event Feed -->
      <div class="event-feed">
        <h3>🔴 Live Event Feed</h3>
        <div class="events">
          <div
            v-for="(event, index) in recentEvents"
            :key="index"
            :class="['event', event.type]"
          >
            <span class="timestamp">{{ formatTime(event.timestamp) }}</span>
            <span class="message">{{ event.message }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, onMounted, onUnmounted } from 'vue'
import api from './services/api'
import ws from './services/websocket'

export default {
  name: 'App',
  setup() {
    const user = ref(null)
    const showLogin = ref(true)
    const email = ref('')
    const username = ref('')
    const password = ref('')

    const myNotes = ref([])
    const publicNotes = ref([])
    const newNote = ref({
      title: '',
      content: '',
      tags: '',
      isPublic: false
    })

    const recentEvents = ref([])

    // Authentication
    const handleAuth = async () => {
      try {
        if (showLogin.value) {
          const data = await api.login(email.value, password.value)
          user.value = data.user
        } else {
          const data = await api.register(
            email.value,
            username.value,
            password.value
          )
          user.value = data.user
        }
        email.value = ''
        username.value = ''
        password.value = ''
        loadNotes()
      } catch (err) {
        alert(err.response?.data?.error || 'Authentication failed')
      }
    }

    const logout = () => {
      user.value = null
      myNotes.value = []
      localStorage.removeItem('auth_token')
    }

    // Notes
    const loadNotes = async () => {
      try {
        myNotes.value = await api.getMyNotes()
        publicNotes.value = await api.getPublicNotes()
      } catch (err) {
        console.error('Failed to load notes:', err)
      }
    }

    const createNote = async () => {
      try {
        const tags = newNote.value.tags
          .split(',')
          .map(t => t.trim())
          .filter(t => t)

        await api.createNote(
          newNote.value.title,
          newNote.value.content,
          tags,
          newNote.value.isPublic
        )

        newNote.value = { title: '', content: '', tags: '', isPublic: false }
        loadNotes()
      } catch (err) {
        alert(err.response?.data?.error || 'Failed to create note')
      }
    }

    const deleteNote = async (id) => {
      try {
        await api.deleteNote(id)
        loadNotes()
      } catch (err) {
        alert(err.response?.data?.error || 'Failed to delete note')
      }
    }

    const shareNote = async (id) => {
      try {
        await api.shareNote(id)
        loadNotes()
      } catch (err) {
        alert(err.response?.data?.error || 'Failed to share note')
      }
    }

    // WebSocket event handlers
    const handleNoteEvent = (data) => {
      const eventMessages = {
        created: `New note created: "${data.title}"`,
        updated: `Note updated: "${data.title}"`,
        deleted: `Note deleted: "${data.title}"`,
        shared: `Note shared publicly: "${data.title}"`
      }

      addEvent(data.type, eventMessages[data.type] || 'Note event')

      // Reload notes to show changes
      if (user.value) {
        loadNotes()
      }
    }

    const handleUserEvent = (data) => {
      const eventMessages = {
        created: `New user registered: ${data.username}`,
        logged_in: `User logged in: ${data.username}`
      }

      addEvent(data.type, eventMessages[data.type] || 'User event')
    }

    const addEvent = (type, message) => {
      recentEvents.value.unshift({
        type,
        message,
        timestamp: new Date()
      })

      // Keep only last 10 events
      if (recentEvents.value.length > 10) {
        recentEvents.value.pop()
      }
    }

    const formatTime = (date) => {
      return new Date(date).toLocaleTimeString()
    }

    // Lifecycle
    onMounted(() => {
      ws.connect()
      ws.on('note.*', handleNoteEvent)
      ws.on('user.*', handleUserEvent)

      loadNotes()
    })

    onUnmounted(() => {
      ws.off('note.*', handleNoteEvent)
      ws.off('user.*', handleUserEvent)
      ws.disconnect()
    })

    return {
      user,
      showLogin,
      email,
      username,
      password,
      myNotes,
      publicNotes,
      newNote,
      recentEvents,
      handleAuth,
      logout,
      createNote,
      deleteNote,
      shareNote,
      formatTime
    }
  }
}
</script>

<style scoped>
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
  padding-bottom: 20px;
  border-bottom: 2px solid #e0e0e0;
}

.auth-section {
  max-width: 400px;
  margin: 50px auto;
}

.auth-form {
  background: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

form {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

input, textarea {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.btn-primary {
  background: #007bff;
  color: white;
  padding: 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 600;
}

.btn-primary:hover {
  background: #0056b3;
}

.btn-secondary {
  background: #6c757d;
  color: white;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.notes-section {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.create-note {
  grid-column: 1 / -1;
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.notes-list {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.note-card {
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 15px;
  margin-bottom: 15px;
  transition: transform 0.2s;
}

.note-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}

.note-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}

.badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
}

.badge.public {
  background: #28a745;
  color: white;
}

.badge.private {
  background: #ffc107;
  color: #333;
}

.note-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
}

.tags {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.tag {
  background: #e9ecef;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  color: #495057;
}

.actions {
  display: flex;
  gap: 8px;
}

.btn-small {
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
  background: #007bff;
  color: white;
}

.btn-danger {
  background: #dc3545;
}

.event-feed {
  grid-column: 1 / -1;
  background: #1a1a1a;
  color: #fff;
  padding: 20px;
  border-radius: 8px;
  max-height: 300px;
  overflow-y: auto;
}

.events {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.event {
  padding: 8px 12px;
  border-radius: 4px;
  background: #2a2a2a;
  font-size: 13px;
  display: flex;
  gap: 12px;
}

.timestamp {
  color: #888;
  font-family: monospace;
}

.empty-state {
  text-align: center;
  padding: 40px;
  color: #999;
}
</style>

Part 5: Docker Compose Setup

Create docker-compose.yml to run everything together:

version: '3.8'

services:
  nats:
    image: nats:latest
    ports:
      - "4222:4222"
      - "8222:8222"
    command: "-js -m 8222"

  user-service:
    build:
      context: ./services/user-service
    ports:
      - "8081:8081"
    depends_on:
      - nats
    environment:
      - NATS_URL=nats://nats:4222

  note-service:
    build:
      context: ./services/note-service
    ports:
      - "8082:8082"
    depends_on:
      - nats
    environment:
      - NATS_URL=nats://nats:4222

  gateway:
    build:
      context: ./services/gateway
    ports:
      - "8083:8083"
    depends_on:
      - nats
    environment:
      - NATS_URL=nats://nats:4222

  frontend:
    build:
      context: ./frontend
    ports:
      - "5173:5173"
    depends_on:
      - user-service
      - note-service
      - gateway

Dependencies (go.mod)

module event-driven-notes

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    github.com/golang-jwt/jwt/v5 v5.2.0
    github.com/google/uuid v1.5.0
    github.com/gorilla/websocket v1.5.1
    github.com/nats-io/nats.go v1.31.0
    golang.org/x/crypto v0.17.0
)

Running the System

  1. Start NATS server:
docker run -p 4222:4222 -p 8222:8222 nats:latest -js -m 8222
  1. Run User Service:
cd services/user-service
go run *.go
  1. Run Note Service:
cd services/note-service
go run *.go
  1. Run WebSocket Gateway:
cd services/gateway
go run *.go
  1. Run Frontend:
cd frontend
npm run dev

Testing the System

Test Event Publishing

# Register a user
curl -X POST http://localhost:8081/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","username":"alice","password":"password123"}'

# Create a note (use token from register response)
curl -X POST http://localhost:8082/notes \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -d '{"title":"My First Note","content":"Hello NATS!","is_public":true}'

# Share a note
curl -X POST http://localhost:8082/notes/NOTE_ID/share \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"

Key Architectural Decisions

1. Subject-Based Routing

NATS uses hierarchical subjects for routing:

  • user.created - New user registration
  • user.logged_in - User login
  • note.created - New note
  • note.updated - Note modification
  • note.deleted - Note deletion
  • note.shared - Note made public

Services subscribe using wildcards:

  • user.* - All user events
  • note.* - All note events
  • > - All events (admin monitoring)

2. Decoupled Services

Each service operates independently:

  • User Service doesn’t know about Note Service
  • Note Service subscribes to user events but doesn’t call User Service
  • Gateway bridges events to WebSocket without business logic

3. At-Most-Once Delivery

This implementation uses NATS Core (pub/sub) which provides at-most-once delivery. For guaranteed delivery, upgrade to NATS JetStream:

// Create a JetStream context
js, _ := nc.JetStream()

// Create a stream
js.AddStream(&nats.StreamConfig{
    Name:     "NOTES",
    Subjects: []string{"note.>"},
})

// Publish with acknowledgment
js.Publish("note.created", data)

4. Security Considerations

Production Improvements:

  • Use HTTPS/TLS for all services
  • Implement NATS authentication
  • Add rate limiting
  • Validate all inputs
  • Use real databases (PostgreSQL, MongoDB)
  • Implement proper error handling
  • Add structured logging
  • Use environment variables for secrets

Performance Characteristics

NATS Throughput:

  • Single NATS server: ~11M+ messages/second
  • Latency: Sub-millisecond for local network
  • Lightweight: 12MB memory footprint

Scaling Strategy:

  • Run multiple instances of each service
  • NATS handles load balancing automatically
  • Use NATS clustering for high availability
  • Add Redis for session storage

Monitoring & Observability

Monitor NATS using the built-in monitoring endpoint:

curl http://localhost:8222/varz

Add Prometheus metrics:

import "github.com/nats-io/nats.go/prometheus"

// Expose metrics
http.Handle("/metrics", promhttp.Handler())

Common Patterns

Fan-Out Pattern

One publisher, multiple subscribers:

// Publisher
nc.Publish("note.created", data)

// Multiple services subscribe
noteService.Subscribe("note.*", handler)
analyticsService.Subscribe("note.*", handler)
notificationService.Subscribe("note.*", handler)

Request-Reply Pattern

Synchronous RPC over NATS:

// Requestor
msg, err := nc.Request("user.get", []byte(userID), time.Second)

// Responder
nc.Subscribe("user.get", func(msg *nats.Msg) {
    user := getUser(string(msg.Data))
    msg.Respond([]byte(user))
})

Queue Groups

Load balancing across multiple instances:

// Multiple workers, only one processes each message
nc.QueueSubscribe("note.process", "workers", handler)

When to Use This Architecture

Perfect For:

  • Microservices communication
  • Real-time dashboards
  • Activity feeds
  • Notification systems
  • Event sourcing
  • Audit logs

Avoid When:

  • Simple monolithic apps
  • No real-time requirements
  • Extremely high durability needs (use Kafka)
  • Complex transactions across services

Next Steps

  1. Add JetStream for persistent streams and replay
  2. Implement CQRS with event sourcing
  3. Add Saga pattern for distributed transactions
  4. Use NATS KV for distributed configuration
  5. Implement Circuit Breakers for resilience

Conclusion

Event-driven architecture with NATS provides a lightweight, fast, and scalable foundation for microservices. This implementation demonstrates:

  • ✅ Decoupled services communicating via events
  • ✅ Real-time updates pushed to browsers via WebSocket
  • ✅ Simple pub/sub with hierarchical subjects
  • ✅ Production-ready patterns (auth, CORS, error handling)
  • ✅ Horizontal scalability

The beauty of NATS is its simplicity—no complex brokers, no configuration overhead, just fast message passing that gets out of your way.

Source code: Complete implementation available on GitHub

Related Posts:


Questions? Reach me at [email protected] or @colossus21