Back to blog
Backend, Go, Tutorial17 min read

Building a Go App with Supabase (Clean Architecture)

Learn how to build a production-ready Go application with Supabase using Clean Architecture principles and a Dependency Injection Container pattern.

Complete Guide: Building a Go App with Supabase (Clean Architecture)

๐Ÿ“š Table of Contents

  1. Project Setup
  2. Project Structure
  3. Understanding Clean Architecture
  4. Step-by-Step Implementation
  5. Running the Application

๐Ÿš€ Project Setup

Prerequisites

  • Go 1.21+ installed
  • Supabase account (free tier is fine)
  • Basic terminal/command line knowledge

Step 1: Create Supabase Project

  1. Go to https://supabase.com
  2. Create a new project
  3. Wait for database provisioning
  4. Go to Settings โ†’ API and note:
    • Project URL (e.g., https://xxxxx.supabase.co)
    • anon public key (starts with eyJ...)

Step 2: Create Database Table

  1. In Supabase dashboard, go to SQL Editor
  2. Run this SQL:
-- Create tasks table
CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  description TEXT,
  completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable Row Level Security (RLS)
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

-- Create policy to allow all operations (for demo purposes)
CREATE POLICY "Allow all operations" ON tasks
  FOR ALL
  USING (true)
  WITH CHECK (true);

Step 3: Initialize Go Project

# Create project directory
mkdir go-supabase-demo
cd go-supabase-demo

# Initialize Go module
go mod init github.com/yourusername/go-supabase-demo

# Install dependencies
go get github.com/supabase-community/supabase-go
go get github.com/joho/godotenv
go get github.com/google/uuid

๐Ÿ“ Project Structure

go-supabase-demo/
โ”œโ”€โ”€ cmd/
โ”‚   โ””โ”€โ”€ api/
โ”‚       โ””โ”€โ”€ main.go              # Application entry point
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ domain/
โ”‚   โ”‚   โ””โ”€โ”€ task.go              # Business entities
โ”‚   โ”œโ”€โ”€ repository/
โ”‚   โ”‚   โ””โ”€โ”€ task_repository.go  # Data access layer
โ”‚   โ”œโ”€โ”€ usecase/
โ”‚   โ”‚   โ””โ”€โ”€ task_usecase.go     # Business logic
โ”‚   โ””โ”€โ”€ handler/
โ”‚       โ””โ”€โ”€ task_handler.go     # HTTP handlers
โ”œโ”€โ”€ pkg/
โ”‚   โ”œโ”€โ”€ container/
โ”‚   โ”‚   โ””โ”€โ”€ container.go         # Dependency injection container
โ”‚   โ””โ”€โ”€ database/
โ”‚       โ””โ”€โ”€ supabase.go          # Supabase client setup
โ”œโ”€โ”€ .env                          # Environment variables
โ”œโ”€โ”€ go.mod                        # Go dependencies
โ””โ”€โ”€ go.sum                        # Dependency checksums

Why this structure?

  • cmd/: Entry points for different applications
  • internal/: Private application code (can't be imported by other projects)
  • pkg/: Public libraries (can be imported by other projects)
  • pkg/container/: Centralized dependency management
  • Each layer has a single responsibility

๐Ÿ—๏ธ Understanding Clean Architecture

Clean Architecture separates concerns into layers:

                    main.go
                       โ”‚
                       โ–ผ
            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
            โ”‚    Container     โ”‚  โ† Dependency Injection
            โ”‚   (Wires all     โ”‚
            โ”‚  dependencies)   โ”‚
            โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
                     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Handler Layer               โ”‚  โ† HTTP/API Layer
โ”‚    (Receives requests, returns      โ”‚
โ”‚         responses)                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚        UseCase Layer                โ”‚  โ† Business Logic
โ”‚   (Orchestrates operations,         โ”‚
โ”‚    enforces business rules)         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚      Repository Layer               โ”‚  โ† Data Access
โ”‚   (Talks to database/external       โ”‚
โ”‚         services)                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚        Domain Layer                 โ”‚  โ† Core Entities
โ”‚    (Business entities/models)       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Benefits:

  • Testable: Each layer can be tested independently
  • Maintainable: Changes in one layer don't affect others
  • Scalable: Easy to add new features
  • Database-agnostic: Can swap Supabase for another DB easily
  • Clean Dependency Flow: Container manages all wiring in one place

๐Ÿ”จ Step-by-Step Implementation

Step 4: Create Environment File

Create .env in project root:

SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-anon-key-here
PORT=8080

Step 5: Domain Layer (Entities)

Create internal/domain/task.go:

package domain

import (
	"time"
	"github.com/google/uuid"
)

// Task represents our core business entity
// This is framework-agnostic and contains only business data
type Task struct {
	ID          uuid.UUID  `json:"id"`
	Title       string     `json:"title"`
	Description *string    `json:"description"` // Pointer because it's nullable
	Completed   bool       `json:"completed"`
	CreatedAt   time.Time  `json:"created_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
}

// CreateTaskDTO is used when creating a new task
// DTO = Data Transfer Object
type CreateTaskDTO struct {
	Title       string  `json:"title"`
	Description *string `json:"description,omitempty"`
}

// UpdateTaskDTO is used when updating a task
type UpdateTaskDTO struct {
	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`
	Completed   *bool   `json:"completed,omitempty"`
}

// Validate checks if CreateTaskDTO has valid data
func (dto CreateTaskDTO) Validate() error {
	if dto.Title == "" {
		return ErrInvalidInput
	}
	return nil
}

Create internal/domain/errors.go:

package domain

import "errors"

// Define domain-specific errors
var (
	ErrTaskNotFound = errors.New("task not found")
	ErrInvalidInput = errors.New("invalid input")
)

Step 6: Repository Layer (Data Access)

Create internal/repository/task_repository.go:

package repository

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/google/uuid"
	supabase "github.com/supabase-community/supabase-go"
	"github.com/yourusername/go-supabase-demo/internal/domain"
)

// TaskRepository defines the interface for task data operations
// Using an interface allows us to swap implementations easily
type TaskRepository interface {
	Create(ctx context.Context, task *domain.CreateTaskDTO) (*domain.Task, error)
	GetByID(ctx context.Context, id uuid.UUID) (*domain.Task, error)
	GetAll(ctx context.Context) ([]domain.Task, error)
	Update(ctx context.Context, id uuid.UUID, dto *domain.UpdateTaskDTO) (*domain.Task, error)
	Delete(ctx context.Context, id uuid.UUID) error
}

// supabaseTaskRepository implements TaskRepository using Supabase
type supabaseTaskRepository struct {
	client *supabase.Client
}

// NewTaskRepository creates a new task repository
func NewTaskRepository(client *supabase.Client) TaskRepository {
	return &supabaseTaskRepository{
		client: client,
	}
}

// Create inserts a new task into the database
func (r *supabaseTaskRepository) Create(ctx context.Context, dto *domain.CreateTaskDTO) (*domain.Task, error) {
	// Prepare data for insertion
	data := map[string]interface{}{
		"title":       dto.Title,
		"description": dto.Description,
		"completed":   false,
	}

	// Execute insert query
	result, _, err := r.client.From("tasks").Insert(data, false, "", "", "").Execute()
	if err != nil {
		return nil, fmt.Errorf("failed to create task: %w", err)
	}

	// Parse response
	var tasks []domain.Task
	if err := json.Unmarshal(result, &tasks); err != nil {
		return nil, fmt.Errorf("failed to parse response: %w", err)
	}

	if len(tasks) == 0 {
		return nil, fmt.Errorf("no task returned after creation")
	}

	return &tasks[0], nil
}

// GetByID retrieves a single task by its ID
func (r *supabaseTaskRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
	result, _, err := r.client.From("tasks").
		Select("*", "exact", false).
		Eq("id", id.String()).
		Single().
		Execute()
	
	if err != nil {
		return nil, fmt.Errorf("failed to get task: %w", err)
	}

	var task domain.Task
	if err := json.Unmarshal(result, &task); err != nil {
		return nil, fmt.Errorf("failed to parse task: %w", err)
	}

	return &task, nil
}

// GetAll retrieves all tasks
func (r *supabaseTaskRepository) GetAll(ctx context.Context) ([]domain.Task, error) {
	result, _, err := r.client.From("tasks").
		Select("*", "exact", false).
		Order("created_at", &supabase.OrderOpts{Ascending: false}).
		Execute()
	
	if err != nil {
		return nil, fmt.Errorf("failed to get tasks: %w", err)
	}

	var tasks []domain.Task
	if err := json.Unmarshal(result, &tasks); err != nil {
		return nil, fmt.Errorf("failed to parse tasks: %w", err)
	}

	return tasks, nil
}

// Update modifies an existing task
func (r *supabaseTaskRepository) Update(ctx context.Context, id uuid.UUID, dto *domain.UpdateTaskDTO) (*domain.Task, error) {
	// Build update data (only include non-nil fields)
	data := make(map[string]interface{})
	if dto.Title != nil {
		data["title"] = *dto.Title
	}
	if dto.Description != nil {
		data["description"] = *dto.Description
	}
	if dto.Completed != nil {
		data["completed"] = *dto.Completed
	}
	data["updated_at"] = "now()"

	result, _, err := r.client.From("tasks").
		Update(data, "", "").
		Eq("id", id.String()).
		Execute()
	
	if err != nil {
		return nil, fmt.Errorf("failed to update task: %w", err)
	}

	var tasks []domain.Task
	if err := json.Unmarshal(result, &tasks); err != nil {
		return nil, fmt.Errorf("failed to parse updated task: %w", err)
	}

	if len(tasks) == 0 {
		return nil, domain.ErrTaskNotFound
	}

	return &tasks[0], nil
}

// Delete removes a task from the database
func (r *supabaseTaskRepository) Delete(ctx context.Context, id uuid.UUID) error {
	_, _, err := r.client.From("tasks").
		Delete("", "").
		Eq("id", id.String()).
		Execute()
	
	if err != nil {
		return fmt.Errorf("failed to delete task: %w", err)
	}

	return nil
}

Step 7: UseCase Layer (Business Logic)

Create internal/usecase/task_usecase.go:

package usecase

import (
	"context"

	"github.com/google/uuid"
	"github.com/yourusername/go-supabase-demo/internal/domain"
	"github.com/yourusername/go-supabase-demo/internal/repository"
)

// TaskUseCase defines business operations for tasks
type TaskUseCase interface {
	CreateTask(ctx context.Context, dto *domain.CreateTaskDTO) (*domain.Task, error)
	GetTask(ctx context.Context, id uuid.UUID) (*domain.Task, error)
	ListTasks(ctx context.Context) ([]domain.Task, error)
	UpdateTask(ctx context.Context, id uuid.UUID, dto *domain.UpdateTaskDTO) (*domain.Task, error)
	DeleteTask(ctx context.Context, id uuid.UUID) error
}

// taskUseCase implements TaskUseCase
type taskUseCase struct {
	taskRepo repository.TaskRepository
}

// NewTaskUseCase creates a new task use case
func NewTaskUseCase(taskRepo repository.TaskRepository) TaskUseCase {
	return &taskUseCase{
		taskRepo: taskRepo,
	}
}

// CreateTask creates a new task with validation
func (uc *taskUseCase) CreateTask(ctx context.Context, dto *domain.CreateTaskDTO) (*domain.Task, error) {
	// Validate input
	if err := dto.Validate(); err != nil {
		return nil, err
	}

	// Business logic: You could add more rules here
	// For example: check user permissions, apply business rules, etc.

	// Delegate to repository
	return uc.taskRepo.Create(ctx, dto)
}

// GetTask retrieves a task by ID
func (uc *taskUseCase) GetTask(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
	return uc.taskRepo.GetByID(ctx, id)
}

// ListTasks retrieves all tasks
func (uc *taskUseCase) ListTasks(ctx context.Context) ([]domain.Task, error) {
	return uc.taskRepo.GetAll(ctx)
}

// UpdateTask updates a task
func (uc *taskUseCase) UpdateTask(ctx context.Context, id uuid.UUID, dto *domain.UpdateTaskDTO) (*domain.Task, error) {
	// You could add validation here
	return uc.taskRepo.Update(ctx, id, dto)
}

// DeleteTask deletes a task
func (uc *taskUseCase) DeleteTask(ctx context.Context, id uuid.UUID) error {
	return uc.taskRepo.Delete(ctx, id)
}

Step 8: Handler Layer (HTTP API)

Create internal/handler/task_handler.go:

package handler

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

	"github.com/google/uuid"
	"github.com/yourusername/go-supabase-demo/internal/domain"
	"github.com/yourusername/go-supabase-demo/internal/usecase"
)

// TaskHandler handles HTTP requests for tasks
type TaskHandler struct {
	taskUseCase usecase.TaskUseCase
}

// NewTaskHandler creates a new task handler
func NewTaskHandler(taskUseCase usecase.TaskUseCase) *TaskHandler {
	return &TaskHandler{
		taskUseCase: taskUseCase,
	}
}

// Response is a generic API response wrapper
type Response struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
}

// CreateTask handles POST /tasks
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var dto domain.CreateTaskDTO
	if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
		respondError(w, http.StatusBadRequest, "Invalid request body")
		return
	}

	task, err := h.taskUseCase.CreateTask(r.Context(), &dto)
	if err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}

	respondJSON(w, http.StatusCreated, task)
}

// GetTask handles GET /tasks/{id}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
	// Extract ID from URL path
	idStr := r.PathValue("id")
	id, err := uuid.Parse(idStr)
	if err != nil {
		respondError(w, http.StatusBadRequest, "Invalid task ID")
		return
	}

	task, err := h.taskUseCase.GetTask(r.Context(), id)
	if err != nil {
		respondError(w, http.StatusNotFound, err.Error())
		return
	}

	respondJSON(w, http.StatusOK, task)
}

// ListTasks handles GET /tasks
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
	tasks, err := h.taskUseCase.ListTasks(r.Context())
	if err != nil {
		respondError(w, http.StatusInternalServerError, err.Error())
		return
	}

	respondJSON(w, http.StatusOK, tasks)
}

// UpdateTask handles PUT /tasks/{id}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := uuid.Parse(idStr)
	if err != nil {
		respondError(w, http.StatusBadRequest, "Invalid task ID")
		return
	}

	var dto domain.UpdateTaskDTO
	if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
		respondError(w, http.StatusBadRequest, "Invalid request body")
		return
	}

	task, err := h.taskUseCase.UpdateTask(r.Context(), id, &dto)
	if err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}

	respondJSON(w, http.StatusOK, task)
}

// DeleteTask handles DELETE /tasks/{id}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := uuid.Parse(idStr)
	if err != nil {
		respondError(w, http.StatusBadRequest, "Invalid task ID")
		return
	}

	if err := h.taskUseCase.DeleteTask(r.Context(), id); err != nil {
		respondError(w, http.StatusInternalServerError, err.Error())
		return
	}

	respondJSON(w, http.StatusOK, map[string]string{"message": "Task deleted successfully"})
}

// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Response{
		Success: true,
		Data:    data,
	})
}

func respondError(w http.ResponseWriter, status int, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Response{
		Success: false,
		Error:   message,
	})
}

Step 9: Supabase Client Setup

Create pkg/database/supabase.go:

package database

import (
	"fmt"
	"os"

	supabase "github.com/supabase-community/supabase-go"
)

// NewSupabaseClient creates and returns a new Supabase client
func NewSupabaseClient() (*supabase.Client, error) {
	supabaseURL := os.Getenv("SUPABASE_URL")
	supabaseKey := os.Getenv("SUPABASE_KEY")

	if supabaseURL == "" || supabaseKey == "" {
		return nil, fmt.Errorf("SUPABASE_URL and SUPABASE_KEY must be set")
	}

	client, err := supabase.NewClient(supabaseURL, supabaseKey, &supabase.ClientOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to initialize Supabase client: %w", err)
	}

	return client, nil
}

Step 10: Dependency Injection Container

Create pkg/container/container.go:

package container

import (
	"sync"

	supabase "github.com/supabase-community/supabase-go"
	"github.com/yourusername/go-supabase-demo/internal/handler"
	"github.com/yourusername/go-supabase-demo/internal/repository"
	"github.com/yourusername/go-supabase-demo/internal/usecase"
)

// Container holds all application dependencies
// This pattern centralizes dependency management and makes testing easier
type Container struct {
	// Database client
	supabaseClient *supabase.Client

	// Repository layer (lazy initialized)
	taskRepo     repository.TaskRepository
	taskRepoOnce sync.Once

	// UseCase layer (lazy initialized)
	taskUseCase     usecase.TaskUseCase
	taskUseCaseOnce sync.Once

	// Handler layer (lazy initialized)
	taskHandler     *handler.TaskHandler
	taskHandlerOnce sync.Once
}

// NewContainer creates a new dependency injection container
func NewContainer(supabaseClient *supabase.Client) *Container {
	return &Container{
		supabaseClient: supabaseClient,
	}
}

// GetTaskRepository returns the task repository instance (singleton)
// sync.Once ensures it's only created once, even with concurrent calls
func (c *Container) GetTaskRepository() repository.TaskRepository {
	c.taskRepoOnce.Do(func() {
		c.taskRepo = repository.NewTaskRepository(c.supabaseClient)
	})
	return c.taskRepo
}

// GetTaskUseCase returns the task use case instance (singleton)
func (c *Container) GetTaskUseCase() usecase.TaskUseCase {
	c.taskUseCaseOnce.Do(func() {
		c.taskUseCase = usecase.NewTaskUseCase(c.GetTaskRepository())
	})
	return c.taskUseCase
}

// GetTaskHandler returns the task handler instance (singleton)
func (c *Container) GetTaskHandler() *handler.TaskHandler {
	c.taskHandlerOnce.Do(func() {
		c.taskHandler = handler.NewTaskHandler(c.GetTaskUseCase())
	})
	return c.taskHandler
}

Why use a Container?

  • Centralized Dependency Management: All dependencies are managed in one place
  • Lazy Initialization: Dependencies are only created when needed
  • Thread-Safe: sync.Once ensures safe concurrent access
  • Easy Testing: Can mock dependencies by creating test containers
  • Scalability: Easy to add new dependencies without changing main.go

Step 11: Main Application Entry Point

Create cmd/api/main.go:

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/joho/godotenv"
	"github.com/yourusername/go-supabase-demo/pkg/container"
	"github.com/yourusername/go-supabase-demo/pkg/database"
)

func main() {
	// Load environment variables from .env file
	if err := godotenv.Load(); err != nil {
		log.Println("No .env file found, using environment variables")
	}

	// Initialize Supabase client
	supabaseClient, err := database.NewSupabaseClient()
	if err != nil {
		log.Fatal("Failed to initialize Supabase client:", err)
	}

	// Initialize dependency injection container
	// The container manages all dependencies and their lifecycles
	appContainer := container.NewContainer(supabaseClient)

	// Get handlers from container (dependencies are auto-wired)
	taskHandler := appContainer.GetTaskHandler()

	// Setup HTTP routes using Go 1.22+ pattern matching
	mux := http.NewServeMux()

	// Task routes
	mux.HandleFunc("GET /tasks", taskHandler.ListTasks)
	mux.HandleFunc("POST /tasks", taskHandler.CreateTask)
	mux.HandleFunc("GET /tasks/{id}", taskHandler.GetTask)
	mux.HandleFunc("PUT /tasks/{id}", taskHandler.UpdateTask)
	mux.HandleFunc("DELETE /tasks/{id}", taskHandler.DeleteTask)

	// Health check endpoint
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("OK"))
	})

	// Get port from environment or use default
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	// Start server
	addr := fmt.Sprintf(":%s", port)
	log.Printf("๐Ÿš€ Server starting on http://localhost%s", addr)
	log.Printf("๐Ÿ“ API endpoints:")
	log.Printf("   GET    /health")
	log.Printf("   GET    /tasks")
	log.Printf("   POST   /tasks")
	log.Printf("   GET    /tasks/{id}")
	log.Printf("   PUT    /tasks/{id}")
	log.Printf("   DELETE /tasks/{id}")

	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatal("Failed to start server:", err)
	}
}

What changed?

  • โœ… Removed manual dependency injection code
  • โœ… Added container initialization: appContainer := container.NewContainer(supabaseClient)
  • โœ… Get dependencies from container: taskHandler := appContainer.GetTaskHandler()
  • โœ… Much cleaner and more maintainable main function
  • โœ… Adding new dependencies only requires updating the container

โ–ถ๏ธ Running the Application

1. Update Import Paths

Replace github.com/yourusername/go-supabase-demo with your actual module name in:

  • internal/repository/task_repository.go
  • internal/usecase/task_usecase.go
  • internal/handler/task_handler.go
  • pkg/container/container.go
  • cmd/api/main.go

2. Install Dependencies

go mod tidy

3. Set Environment Variables

Make sure your .env file has correct values:

SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-anon-key
PORT=8080

4. Run the Application

go run cmd/api/main.go

You should see:

๐Ÿš€ Server starting on http://localhost:8080
๐Ÿ“ API endpoints:
   GET    /health
   GET    /tasks
   POST   /tasks
   ...

๐Ÿงช Testing the API

Create a Task

curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Learn Go",
    "description": "Study clean architecture with Supabase"
  }'

Get All Tasks

curl http://localhost:8080/tasks

Get Single Task

curl http://localhost:8080/tasks/{task-id}

Update a Task

curl -X PUT http://localhost:8080/tasks/{task-id} \
  -H "Content-Type: application/json" \
  -d '{
    "completed": true
  }'

Delete a Task

curl -X DELETE http://localhost:8080/tasks/{task-id}

๐ŸŽ“ Key Concepts Explained

1. Interfaces (repository.TaskRepository, usecase.TaskUseCase)

  • Define contracts for behavior
  • Enable dependency injection
  • Make testing easier (can create mock implementations)

2. Dependency Injection Container

// Container pattern centralizes dependency management
appContainer := container.NewContainer(supabaseClient)
taskHandler := appContainer.GetTaskHandler()  // โ† Auto-wires all dependencies

// Behind the scenes, the container builds:
// taskRepo โ†’ taskUseCase โ†’ taskHandler

Benefits of the Container Pattern:

  • Single Responsibility: Main.go focuses on startup, not wiring
  • Lazy Loading: Dependencies created only when needed
  • Singleton Pattern: Each dependency created once via sync.Once
  • Easy Testing: Can create mock containers for unit tests
  • Scalability: Add new services without cluttering main.go

Example: Testing with Container

// In tests, you can create a test container:
mockRepo := &MockTaskRepository{}
testContainer := &container.Container{
    taskRepo: mockRepo,
}
handler := testContainer.GetTaskHandler()
// Now handler uses your mock!

3. Pointers (e.g., *string, *domain.Task)

  • * means "pointer to"
  • Used for nullable fields or when you want to modify the original
  • nil means "no value"

4. Context (context.Context)

  • Carries deadlines, cancellation signals, and request-scoped values
  • Always pass as first parameter to functions
  • Used for request timeout/cancellation

5. sync.Once (Thread-Safe Initialization)

var instance *MyService
var once sync.Once

func GetInstance() *MyService {
    once.Do(func() {
        instance = &MyService{}  // Only runs once, even with 1000 concurrent calls
    })
    return instance
}
  • Ensures initialization happens exactly once
  • Thread-safe without manual locking
  • Used in our container for singleton pattern

6. Error Handling

if err != nil {
    return nil, fmt.Errorf("descriptive message: %w", err)
}
  • Go doesn't have exceptions; functions return errors
  • Always check errors immediately
  • Use %w to wrap errors for better debugging

๐Ÿš€ Next Steps

  1. Add Authentication: Use Supabase Auth (client.SignInWithEmailPassword)
  2. Add Middleware: Logging, CORS, authentication
  3. Add Tests: Unit tests for each layer
  4. Add Validation: Use a library like go-playground/validator
  5. Add Documentation: Use Swagger/OpenAPI
  6. Containerize: Create a Dockerfile

๐Ÿ“š Additional Resources

Happy coding! ๐ŸŽ‰