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:
- User Service: Handles authentication (register/login) and publishes user events
- Note Service: Manages CRUD operations on notes and publishes note events
- Frontend Gateway: Bridges NATS events to WebSocket for browser clients
: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
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
- Start NATS server:
docker run -p 4222:4222 -p 8222:8222 nats:latest -js -m 8222
- Run User Service:
cd services/user-service
go run *.go
- Run Note Service:
cd services/note-service
go run *.go
- Run WebSocket Gateway:
cd services/gateway
go run *.go
- 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 registrationuser.logged_in- User loginnote.created- New notenote.updated- Note modificationnote.deleted- Note deletionnote.shared- Note made public
Services subscribe using wildcards:
user.*- All user eventsnote.*- 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
- Add JetStream for persistent streams and replay
- Implement CQRS with event sourcing
- Add Saga pattern for distributed transactions
- Use NATS KV for distributed configuration
- 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