So you want to build an online workshop platform. Maybe you’ve noticed how much the world needs better ways to teach people Go programming — a language that’s wonderfully pragmatic but criminally underrated in the dev community. Or perhaps you’re just tired of watching Zoom workshops crash when 500 developers join simultaneously (hello, bandwidth issues). Either way, you’ve landed on Go, which is exactly the right move. Why Go, though? Because Go is the Swiss Army knife of backend development — it’s concurrent by default, compiles to a single binary, and makes your DevOps team smile like they just got free coffee. Building a workshop platform in Go isn’t just a technical choice; it’s a philosophical one. You’re committing to simplicity, performance, and the kind of reliability that lets you sleep at night. This article walks you through building a production-ready online workshop platform that handles real-time interactions, persistent storage, and scales gracefully. We’ll skip the hand-waving and dive straight into architecture, implementation, and the kind of code you’d actually deploy.
Understanding the Architecture
Before we start throwing code at the problem, let’s talk about what we’re actually building. A workshop platform needs to handle:
- Real-time messaging between instructors and participants
- Session management (starting, pausing, ending workshops)
- Participant tracking and role-based permissions
- Resource sharing (slides, code snippets, exercises)
- Event broadcasting (when someone asks a question, everyone needs to know) Here’s how these components fit together:
This architecture is deliberately simple. We’re using Go’s built-in concurrency model to handle multiple WebSocket connections without breaking a sweat, Redis to cache session state for lightning-fast lookups, and PostgreSQL for durable storage. The event bus ensures that when something happens on one server, all connected clients across all servers know about it immediately.
Core Components: The Foundation
Let’s build the foundational types and structures that everything else will depend on. Think of this as our contract with reality — these types define exactly what we’re dealing with.
package models
import (
"time"
)
// Workshop represents a single online workshop session
type Workshop struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
InstructorID string `json:"instructor_id" db:"instructor_id"`
Status string `json:"status" db:"status"` // "scheduled", "live", "completed"
StartTime time.Time `json:"start_time" db:"start_time"`
EndTime time.Time `json:"end_time" db:"end_time"`
MaxParticipants int `json:"max_participants" db:"max_participants"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Participant represents someone attending a workshop
type Participant struct {
ID string `json:"id" db:"id"`
WorkshopID string `json:"workshop_id" db:"workshop_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"` // "instructor", "moderator", "attendee"
JoinedAt time.Time `json:"joined_at" db:"joined_at"`
LeftAt *time.Time `json:"left_at" db:"left_at"`
}
// Message represents a chat message or event in a workshop
type Message struct {
ID string `json:"id" db:"id"`
WorkshopID string `json:"workshop_id" db:"workshop_id"`
SenderID string `json:"sender_id" db:"sender_id"`
Type string `json:"type" db:"type"` // "chat", "question", "announcement"
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Event represents real-time events broadcast to all participants
type Event struct {
Type string `json:"type"` // "user_joined", "message", "slide_changed"
WorkshopID string `json:"workshop_id"`
Payload map[string]interface{} `json:"payload"`
Timestamp time.Time `json:"timestamp"`
}
Why this structure? Because Go values explicitness. We’re not hiding anything in dynamic maps or JSON unmarshaling nightmares. The database tags tell our ORM exactly where to persist these, and the JSON tags handle serialization. Clean, predictable, debuggable.
Building the WebSocket Hub
The heart of our platform is the WebSocket hub — this is where the magic happens. When a user connects, they join a “hub” specific to their workshop. When any event occurs, the hub broadcasts it to all connected clients.
package hub
import (
"fmt"
"sync"
"time"
)
// Client represents a connected WebSocket user
type Client struct {
ID string
WorkshopID string
UserID string
Role string
conn chan interface{}
quit chan bool
}
// Hub manages all clients for a specific workshop
type Hub struct {
workshopID string
clients map[string]*Client
broadcast chan interface{}
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
// NewHub creates a new workshop hub
func NewHub(workshopID string) *Hub {
return &Hub{
workshopID: workshopID,
clients: make(map[string]*Client),
broadcast: make(chan interface{}, 100),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
// Run starts the hub's event loop — this goroutine never exits
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client.ID] = client
h.mu.Unlock()
fmt.Printf("Client %s joined workshop %s\n", client.ID, h.workshopID)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client.ID]; ok {
delete(h.clients, client.ID)
close(client.conn)
}
h.mu.Unlock()
fmt.Printf("Client %s left workshop %s\n", client.ID, h.workshopID)
case message := <-h.broadcast:
h.mu.RLock()
for _, client := range h.clients {
select {
case client.conn <- message:
case <-time.After(100 * time.Millisecond):
// If the client's channel is full, skip this message
// This prevents slow clients from blocking the hub
}
}
h.mu.RUnlock()
}
}
}
// Broadcast sends a message to all connected clients
func (h *Hub) Broadcast(message interface{}) {
select {
case h.broadcast <- message:
case <-time.After(100 * time.Millisecond):
fmt.Printf("Hub broadcast channel full for workshop %s\n", h.workshopID)
}
}
// Register adds a client to the hub
func (h *Hub) Register(client *Client) {
h.register <- client
}
// Unregister removes a client from the hub
func (h *Hub) Unregister(client *Client) {
h.unregister <- client
}
// ClientCount returns the number of connected clients
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
Notice how we’re using channels extensively here? That’s the Go way. Channels enforce safe concurrent access patterns. We’re not fighting shared memory; we’re sharing memory through channels. This is the difference between Go’s approach and traditional mutex-heavy concurrency — it’s cleaner, more composable, and significantly less likely to deadlock.
WebSocket Connection Handler
Now let’s wire up the actual WebSocket connections. This handler will use the gorilla/websocket library, which is the industry standard:
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
"workshop-platform/hub"
"workshop-platform/models"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// In production, validate the origin header properly
return true
},
}
// WebSocketHandler upgrades an HTTP connection to WebSocket
func WebSocketHandler(workshopHub *hub.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
defer conn.Close()
// Extract user info from the request
userID := r.URL.Query().Get("user_id")
role := r.URL.Query().Get("role") // "instructor" or "attendee"
clientID := generateClientID()
// Create a new client
client := &hub.Client{
ID: clientID,
WorkshopID: workshopHub.workshopID,
UserID: userID,
Role: role,
conn: make(chan interface{}, 50),
quit: make(chan bool),
}
// Register with the hub
workshopHub.Register(client)
// Notify other participants that someone joined
joinEvent := &models.Event{
Type: "user_joined",
WorkshopID: workshopHub.workshopID,
Payload: map[string]interface{}{
"user_id": userID,
"role": role,
},
Timestamp: time.Now(),
}
workshopHub.Broadcast(joinEvent)
// Launch goroutines for reading and writing
go readPump(conn, client, workshopHub)
go writePump(conn, client)
}
}
// readPump reads messages from the WebSocket and broadcasts them
func readPump(conn *websocket.Conn, client *hub.Client, workshopHub *hub.Hub) {
defer func() {
workshopHub.Unregister(client)
conn.Close()
// Notify others that the user left
leaveEvent := &models.Event{
Type: "user_left",
WorkshopID: workshopHub.workshopID,
Payload: map[string]interface{}{
"user_id": client.UserID,
},
Timestamp: time.Now(),
}
workshopHub.Broadcast(leaveEvent)
}()
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
var incoming struct {
Type string `json:"type"`
Content string `json:"content"`
Payload map[string]interface{} `json:"payload"`
}
err := conn.ReadJSON(&incoming)
if err != nil {
log.Printf("Read error: %v", err)
break
}
// Process different message types
event := &models.Event{
Type: incoming.Type,
WorkshopID: workshopHub.workshopID,
Payload: map[string]interface{}{
"sender_id": client.UserID,
"sender_role": client.Role,
"content": incoming.Content,
},
Timestamp: time.Now(),
}
workshopHub.Broadcast(event)
// Reset read deadline on successful read
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}
// writePump sends messages from the client's channel to the WebSocket
func writePump(conn *websocket.Conn, client *hub.Client) {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
conn.Close()
}()
for {
select {
case message, ok := <-client.conn:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteJSON(message); err != nil {
return
}
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-client.quit:
return
}
}
}
// generateClientID creates a unique client identifier
func generateClientID() string {
return fmt.Sprintf("%d-%s", time.Now().UnixNano(), uuid.New().String())
}
Here’s where things get interesting. We’re using two goroutines per connection: one for reading (readPump) and one for writing (writePump). This decoupled approach means slow clients don’t block fast ones. The ticker ensures we send periodic pings to detect dead connections. The error handling is defensive — we don’t assume anything about network conditions.
Workshop Service Layer
Now let’s build the business logic layer that manages workshops. This is where we coordinate everything:
package service
import (
"context"
"database/sql"
"fmt"
"time"
"workshop-platform/hub"
"workshop-platform/models"
)
type WorkshopService struct {
db *sql.DB
hubs map[string]*hub.Hub
}
// NewWorkshopService creates a new service instance
func NewWorkshopService(db *sql.DB) *WorkshopService {
return &WorkshopService{
db: db,
hubs: make(map[string]*hub.Hub),
}
}
// CreateWorkshop creates a new workshop and initializes its hub
func (s *WorkshopService) CreateWorkshop(ctx context.Context, workshop *models.Workshop) error {
workshop.ID = generateID()
workshop.CreatedAt = time.Now()
workshop.Status = "scheduled"
query := `
INSERT INTO workshops (id, title, description, instructor_id, status, start_time, end_time, max_participants, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := s.db.ExecContext(ctx, query,
workshop.ID, workshop.Title, workshop.Description, workshop.InstructorID,
workshop.Status, workshop.StartTime, workshop.EndTime, workshop.MaxParticipants,
workshop.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create workshop: %w", err)
}
// Initialize the hub for this workshop
s.hubs[workshop.ID] = hub.NewHub(workshop.ID)
go s.hubs[workshop.ID].Run()
return nil
}
// GetWorkshop retrieves a workshop by ID
func (s *WorkshopService) GetWorkshop(ctx context.Context, workshopID string) (*models.Workshop, error) {
workshop := &models.Workshop{}
query := `
SELECT id, title, description, instructor_id, status, start_time, end_time, max_participants, created_at
FROM workshops
WHERE id = $1
`
err := s.db.QueryRowContext(ctx, query, workshopID).Scan(
&workshop.ID, &workshop.Title, &workshop.Description, &workshop.InstructorID,
&workshop.Status, &workshop.StartTime, &workshop.EndTime, &workshop.MaxParticipants,
&workshop.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workshop not found")
} else if err != nil {
return nil, fmt.Errorf("failed to get workshop: %w", err)
}
return workshop, nil
}
// StartWorkshop transitions a workshop to "live" status
func (s *WorkshopService) StartWorkshop(ctx context.Context, workshopID string) error {
query := `UPDATE workshops SET status = $1 WHERE id = $2`
_, err := s.db.ExecContext(ctx, query, "live", workshopID)
if err != nil {
return fmt.Errorf("failed to start workshop: %w", err)
}
// Broadcast a system event
if h, ok := s.hubs[workshopID]; ok {
h.Broadcast(&models.Event{
Type: "workshop_started",
WorkshopID: workshopID,
Payload: map[string]interface{}{},
Timestamp: time.Now(),
})
}
return nil
}
// EndWorkshop transitions a workshop to "completed" status
func (s *WorkshopService) EndWorkshop(ctx context.Context, workshopID string) error {
query := `UPDATE workshops SET status = $1 WHERE id = $2`
_, err := s.db.ExecContext(ctx, query, "completed", workshopID)
if err != nil {
return fmt.Errorf("failed to end workshop: %w", err)
}
if h, ok := s.hubs[workshopID]; ok {
h.Broadcast(&models.Event{
Type: "workshop_ended",
WorkshopID: workshopID,
Payload: map[string]interface{}{},
Timestamp: time.Now(),
})
}
return nil
}
// GetParticipants returns all participants in a workshop
func (s *WorkshopService) GetParticipants(ctx context.Context, workshopID string) ([]*models.Participant, error) {
query := `
SELECT id, workshop_id, user_id, role, joined_at, left_at
FROM participants
WHERE workshop_id = $1 AND left_at IS NULL
ORDER BY joined_at ASC
`
rows, err := s.db.QueryContext(ctx, query, workshopID)
if err != nil {
return nil, fmt.Errorf("failed to get participants: %w", err)
}
defer rows.Close()
participants := []*models.Participant{}
for rows.Next() {
p := &models.Participant{}
err := rows.Scan(&p.ID, &p.WorkshopID, &p.UserID, &p.Role, &p.JoinedAt, &p.LeftAt)
if err != nil {
return nil, fmt.Errorf("failed to scan participant: %w", err)
}
participants = append(participants, p)
}
return participants, rows.Err()
}
// GetHub returns the hub for a workshop
func (s *WorkshopService) GetHub(workshopID string) *hub.Hub {
return s.hubs[workshopID]
}
func generateID() string {
return fmt.Sprintf("%d-%s", time.Now().UnixNano(), uuid.New().String())
}
This service layer abstracts away the database operations and hub management. When you call StartWorkshop, it doesn’t just update the database — it also broadcasts the event to all connected clients. This is orchestration.
REST API Endpoints
Let’s expose HTTP endpoints for the workshop platform. These will be consumed by your frontend:
package api
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"workshop-platform/service"
"workshop-platform/models"
)
type API struct {
workshopService *service.WorkshopService
}
// NewAPI creates a new API handler
func NewAPI(workshopService *service.WorkshopService) *API {
return &API{
workshopService: workshopService,
}
}
// RegisterRoutes registers all API routes
func (api *API) RegisterRoutes(router *mux.Router) {
router.HandleFunc("/api/v1/workshops", api.createWorkshop).Methods("POST")
router.HandleFunc("/api/v1/workshops/{id}", api.getWorkshop).Methods("GET")
router.HandleFunc("/api/v1/workshops/{id}/start", api.startWorkshop).Methods("POST")
router.HandleFunc("/api/v1/workshops/{id}/end", api.endWorkshop).Methods("POST")
router.HandleFunc("/api/v1/workshops/{id}/participants", api.getParticipants).Methods("GET")
router.HandleFunc("/api/v1/workshops/{id}/ws", api.upgradeWebSocket).Methods("GET")
}
func (api *API) createWorkshop(w http.ResponseWriter, r *http.Request) {
var workshop models.Workshop
if err := json.NewDecoder(r.Body).Decode(&workshop); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := api.workshopService.CreateWorkshop(r.Context(), &workshop); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(workshop)
}
func (api *API) getWorkshop(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workshopID := vars["id"]
workshop, err := api.workshopService.GetWorkshop(r.Context(), workshopID)
if err != nil {
http.Error(w, "Workshop not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(workshop)
}
func (api *API) startWorkshop(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workshopID := vars["id"]
if err := api.workshopService.StartWorkshop(r.Context(), workshopID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "live"})
}
func (api *API) endWorkshop(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workshopID := vars["id"]
if err := api.workshopService.EndWorkshop(r.Context(), workshopID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "completed"})
}
func (api *API) getParticipants(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workshopID := vars["id"]
participants, err := api.workshopService.GetParticipants(r.Context(), workshopID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(participants)
}
func (api *API) upgradeWebSocket(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workshopID := vars["id"]
hub := api.workshopService.GetHub(workshopID)
if hub == nil {
http.Error(w, "Workshop not found", http.StatusNotFound)
return
}
// handlers.WebSocketHandler is defined above
handlers.WebSocketHandler(hub)(w, r)
}
Notice how clean this is? Each endpoint has a single responsibility. The HTTP layer doesn’t know about database queries or WebSocket internals — it delegates to the service layer. This separation of concerns makes testing and maintenance infinitely easier.
Main Application Setup
Let’s wire everything together in the main entry point:
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
"workshop-platform/api"
"workshop-platform/service"
)
func main() {
// Database setup
dbConnStr := fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"),
)
db, err := sql.Open("postgres", dbConnStr)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
log.Fatalf("Failed to ping database: %v", err)
}
log.Println("Connected to database successfully")
// Initialize services
workshopService := service.NewWorkshopService(db)
// Initialize API
apiHandler := api.NewAPI(workshopService)
// Setup routes
router := mux.NewRouter()
apiHandler.RegisterRoutes(router)
// Middleware for CORS
router.Use(corsMiddleware)
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, router); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// corsMiddleware adds CORS headers
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
Database Schema
You’ll need to create the database tables. Here’s a minimal schema to get started:
CREATE TABLE workshops (
id VARCHAR(255) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
instructor_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
max_participants INTEGER DEFAULT 100,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE participants (
id VARCHAR(255) PRIMARY KEY,
workshop_id VARCHAR(255) NOT NULL REFERENCES workshops(id),
user_id VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
joined_at TIMESTAMP NOT NULL,
left_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE messages (
id VARCHAR(255) PRIMARY KEY,
workshop_id VARCHAR(255) NOT NULL REFERENCES workshops(id),
sender_id VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_workshops_instructor ON workshops(instructor_id);
CREATE INDEX idx_participants_workshop ON participants(workshop_id);
CREATE INDEX idx_messages_workshop ON messages(workshop_id);
Performance Optimization Tips
Now that you have the core platform, here are proven techniques to keep it fast: Connection pooling — don’t create a new database connection per request. Set reasonable pool sizes:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
Redis for session caching — store active workshop sessions in Redis instead of querying the database every time. Participants list changes infrequently but gets queried constantly. Message batching — instead of sending individual events, batch them every 100ms. This reduces network overhead dramatically. Graceful shutdown — always close connections cleanly:
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
db.Close()
os.Exit(0)
}()
Deployment Considerations
Go’s greatest strength in production is that you get a single statically-linked binary. No runtime dependencies, no language environment to install. Just go build and deploy.
For scaling, use a reverse proxy (nginx, HAProxy) to load-balance across multiple instances. Each instance runs independently with its own hub pool. The key insight is that hub state doesn’t need to be shared — when a user connects to any instance, they get routed there consistently (sticky sessions), and that instance manages their WebSocket.
For truly massive scale (thousands of concurrent users), you’d layer Redis as a message bus so that hubs across instances can communicate. But you won’t need this until you’re actually at scale. Go’s concurrency model gets you surprisingly far on a single machine.
Final Thoughts
Building an online workshop platform in Go forces you to think clearly about architecture. There’s no magic framework to hide behind; you’re building from first principles. That’s uncomfortable at first, then liberating. The beauty of this approach is that every component is testable, replaceable, and understandable. You can stress-test the WebSocket hub independently from the API layer. You can mock the database for integration tests. You can deploy exactly what you built without runtime surprises. Start here, deploy to production, gather real feedback, and iterate. Go’s pragmatism matches the way successful products actually evolve — by solving real problems with clean, maintainable code. Now go forth and teach the world Go. They clearly need it.
