Learning Go: Building a Production-Mocking Todo Backend from Scratch

Jay Kye
12 min read

My journey learning Go through hands-on development of a Todo backend API. Exploring project structure, testing strategies, GraphQL integration, and the paradigm shift from Node.js to Go's philosophy.

GoGolangBackendGraphQLTestingLearningAPI Development

After months of building web applications with Node.js and TypeScript, I decided to expand my backend toolkit by learning Go. Rather than following tutorials, I took a hands-on approach: building a complete Todo backend API with authentication, GraphQL, and comprehensive testing. This post shares what I learned, the challenges I faced, and the paradigm shifts that come with adopting Go's philosophy.

Why I Chose Go

Coming from a JavaScript/TypeScript background, I was drawn to Go for several compelling reasons:

Performance & Concurrency:

  • Compiled language with native concurrency (goroutines)
  • Significantly faster than Node.js for CPU-intensive tasks
  • Efficient memory usage

Industry Adoption:

  • Used by Docker, Kubernetes, Terraform
  • Strong in DevOps, microservices, and cloud infrastructure
  • Growing demand in the job market

Simplicity:

  • Small language specification
  • Built-in tooling (go fmt, go test, go mod)
  • Strong standard library

Type Safety:

  • Static typing without TypeScript complexity
  • Compile-time error detection
  • Better refactoring confidence

Project Overview: What I Built

I built a full-featured Todo backend API with:

  • User Authentication: Register, login, JWT token refresh
  • Todo CRUD Operations: Create, read, update, delete with ownership checks
  • GraphQL API: Queries, mutations, and subscription placeholders
  • Advanced Features: Filtering, pagination, batch updates
  • Production Readiness: Health checks, CORS, graceful shutdown
  • Comprehensive Testing: Unit, integration, E2E, and mock tests

Tech Stack:

  • Gin (web framework)
  • PostgreSQL with pgx driver
  • gqlgen (GraphQL)
  • JWT authentication
  • Testcontainers & sqlmock for testing

Key Learning #1: Go's Project Structure Philosophy

The Challenge

Coming from Node.js where project structure is highly flexible (and often chaotic), Go's conventions initially felt restrictive. Where do I put my code? What's the difference between cmd, internal, and pkg?

The Solution: Standard Go Layout

I adopted the widely-accepted Go project structure:

/cmd/server          # Entry point (main.go)
/internal            # Private application code
  /auth              # Authentication domain
  /todo              # Todo domain
  /graphql           # GraphQL layer
  /middleware        # Gin middleware
  /database          # DB connection
  /config            # Configuration
/pkg                 # Public reusable packages
/migrations          # SQL migrations

Key Insights

/internal is powerful:

  • Code in /internal cannot be imported by external projects
  • Enforces encapsulation at the language level
  • Prevents unintended API surface exposure

Domain-driven organization:

  • Each domain (auth, todo) has its own folder
  • Colocate models, repository, service, and tests
  • Clear separation of concerns

Tests live with code:

  • auth_service_test.go sits next to auth_service.go
  • Makes finding tests intuitive
  • Encourages writing tests as you code

Learning: Go's opinionated structure actually speeds development once you embrace it. No more debates about folder organization.

Key Learning #2: Dependency Injection Without Frameworks

The Challenge

Node.js developers often reach for DI frameworks (NestJS, InversifyJS). Go has no equivalent—and that's intentional.

The Solution: Constructor Functions

Go uses simple constructor functions for dependency injection:

// Repository layer
type TodoRepository struct {
    db *pgxpool.Pool
}

func NewTodoRepository(db *pgxpool.Pool) *TodoRepository {
    return &TodoRepository{db: db}
}

// Service layer
type TodoService struct {
    repo TodoRepository
}

func NewTodoService(repo TodoRepository) *TodoService {
    return &TodoService{repo: repo}
}

// Wiring in main.go
func main() {
    db := database.Connect()
    todoRepo := todo.NewTodoRepository(db)
    todoService := todo.NewTodoService(todoRepo)
    // ...
}

Key Insights

Explicit is better than magic:

  • No annotations, no reflection
  • Dependencies flow clearly from main.go down
  • Easy to trace and understand

Perfect for testing:

  • Inject mocks instead of real dependencies
  • No complex test setup
  • Pure functions are naturally testable

Interfaces for flexibility:

type TodoRepository interface {
    Create(ctx context.Context, todo *Todo) error
    FindByUserID(ctx context.Context, userID int) ([]*Todo, error)
}

// Real implementation
type PostgresTodoRepository struct { /* ... */ }

// Mock implementation for tests
type MockTodoRepository struct { /* ... */ }

Learning: Dependency injection in Go is simpler and more explicit than framework-based approaches. It forces you to think about architecture.

Key Learning #3: Testing is First-Class in Go

The Challenge

Testing was my biggest learning curve. Go's testing philosophy differs significantly from JavaScript:

  • No Jest or Mocha—use the standard library
  • Table-driven tests are idiomatic
  • Multiple testing layers: unit, integration, E2E

The Solution: Comprehensive Testing Strategy

I implemented four levels of testing:

1. Unit Tests with Mocks

func TestTodoService_CreateTodo(t *testing.T) {
    tests := []struct {
        name    string
        input   *Todo
        wantErr bool
    }{
        {
            name:    "valid todo",
            input:   &Todo{Title: "Test", UserID: 1},
            wantErr: false,
        },
        {
            name:    "empty title",
            input:   &Todo{Title: "", UserID: 1},
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockRepo := &MockTodoRepository{}
            service := NewTodoService(mockRepo)
            
            err := service.CreateTodo(context.Background(), tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("got error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

2. Integration Tests with Testcontainers

func TestTodoRepository_Integration(t *testing.T) {
    // Spin up real Postgres container
    ctx := context.Background()
    container, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:15"),
    )
    require.NoError(t, err)
    defer container.Terminate(ctx)
    
    // Run migrations
    connString, _ := container.ConnectionString(ctx)
    db := connectDB(connString)
    
    // Test with real DB
    repo := NewTodoRepository(db)
    todo := &Todo{Title: "Test", UserID: 1}
    
    err = repo.Create(ctx, todo)
    assert.NoError(t, err)
    assert.NotZero(t, todo.ID)
}

3. E2E Tests with httptest

func TestTodoAPI_E2E(t *testing.T) {
    // Setup test server
    router := setupRouter()
    server := httptest.NewServer(router)
    defer server.Close()
    
    // Register user
    resp := registerUser(server.URL, "test@example.com", "password")
    require.Equal(t, http.StatusCreated, resp.StatusCode)
    
    // Login and get token
    token := loginUser(server.URL, "test@example.com", "password")
    
    // Create todo with auth token
    todo := createTodo(server.URL, token, "Test Todo")
    assert.NotEmpty(t, todo.ID)
    
    // Verify ownership
    todos := getTodos(server.URL, token)
    assert.Len(t, todos, 1)
}

4. Mock DB Tests with sqlmock

func TestTodoRepository_Create_MockDB(t *testing.T) {
    db, mock, err := sqlmock.New()
    require.NoError(t, err)
    defer db.Close()
    
    // Set expectations
    mock.ExpectQuery("INSERT INTO todos").
        WithArgs("Test Todo", 1).
        WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
    
    repo := NewTodoRepository(db)
    todo := &Todo{Title: "Test Todo", UserID: 1}
    
    err = repo.Create(context.Background(), todo)
    assert.NoError(t, err)
    assert.Equal(t, 1, todo.ID)
    
    // Verify all expectations met
    assert.NoError(t, mock.ExpectationsWereMet())
}

Key Insights

Table-driven tests are elegant:

  • Define test cases as data structures
  • Loop through cases with t.Run()
  • Easy to add new test cases
  • Self-documenting

Testcontainers changed my testing:

  • Real Postgres in Docker for integration tests
  • No more mocking complex DB interactions
  • Confidence in production behavior
  • Automatic cleanup

httptest is underrated:

  • Test full HTTP server without network
  • Complete request/response cycle
  • Auth, middleware, routing—everything

sqlmock for edge cases:

  • Test DB errors and edge cases
  • Faster than real DB
  • Verify exact queries

Learning: Go's testing ecosystem encourages comprehensive testing. The barrier to writing tests is remarkably low.

Key Learning #4: GraphQL with gqlgen

The Challenge

I wanted to add GraphQL alongside REST endpoints. In Node.js, I'd use Apollo Server. Go's ecosystem is different.

The Solution: gqlgen (Code-First Approach)

gqlgen generates Go code from GraphQL schemas:

1. Define Schema

type Query {
    todos(filter: TodoFilter, pagination: Pagination): [Todo!]!
    todo(id: ID!): Todo
}

type Mutation {
    createTodo(input: CreateTodoInput!): Todo!
    updateTodo(id: ID!, input: UpdateTodoInput!): Todo!
    deleteTodo(id: ID!): Boolean!
}

type Todo {
    id: ID!
    title: String!
    completed: Boolean!
    user: User!
}

2. Generate Code

go run github.com/99designs/gqlgen generate

3. Implement Resolvers

func (r *mutationResolver) CreateTodo(
    ctx context.Context, 
    input model.CreateTodoInput,
) (*model.Todo, error) {
    // Get user from context (set by auth middleware)
    userID := ctx.Value("userID").(int)
    
    todo := &todo.Todo{
        Title:     input.Title,
        Completed: false,
        UserID:    userID,
    }
    
    err := r.TodoService.CreateTodo(ctx, todo)
    if err != nil {
        return nil, err
    }
    
    return toGraphQLTodo(todo), nil
}

Key Insights

Type safety across stack:

  • Schema changes regenerate Go types
  • Compile-time errors for mismatches
  • No runtime type surprises

Context for auth:

  • Middleware adds user to context.Context
  • Resolvers extract user from context
  • Clean separation of concerns

Testing GraphQL is straightforward:

func TestTodoResolver(t *testing.T) {
    mockService := &MockTodoService{}
    resolver := &Resolver{TodoService: mockService}
    
    client := client.New(handler.NewDefaultServer(
        generated.NewExecutableSchema(generated.Config{
            Resolvers: resolver,
        }),
    ))
    
    var resp struct {
        CreateTodo model.Todo
    }
    
    client.MustPost(`
        mutation {
            createTodo(input: {title: "Test"}) {
                id
                title
            }
        }
    `, &resp)
    
    assert.Equal(t, "Test", resp.CreateTodo.Title)
}

Learning: gqlgen's code generation approach feels more natural in Go than schema-first approaches. Type safety from schema to database is powerful.

Key Learning #5: Error Handling Done Right

The Challenge

Coming from JavaScript's try/catch, Go's explicit error handling felt verbose. Every function returns (result, error).

The Solution: Embrace Explicit Errors

Multiple return values:

func (r *TodoRepository) FindByID(
    ctx context.Context, 
    id int,
) (*Todo, error) {
    var todo Todo
    err := r.db.QueryRow(ctx, 
        "SELECT id, title, completed FROM todos WHERE id = $1", 
        id,
    ).Scan(&todo.ID, &todo.Title, &todo.Completed)
    
    if err == pgx.ErrNoRows {
        return nil, fmt.Errorf("todo not found: %w", ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("query error: %w", err)
    }
    
    return &todo, nil
}

Custom errors:

var (
    ErrNotFound     = errors.New("resource not found")
    ErrUnauthorized = errors.New("unauthorized access")
    ErrInvalidInput = errors.New("invalid input")
)

// Wrapping errors preserves context
func (s *TodoService) GetTodo(ctx context.Context, id int) (*Todo, error) {
    todo, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("service layer: %w", err)
    }
    return todo, nil
}

// Checking wrapped errors
if errors.Is(err, ErrNotFound) {
    return nil, status.Error(codes.NotFound, "todo not found")
}

Key Insights

Explicit > Implicit:

  • Errors are values, not exceptions
  • Forces you to handle errors at each level
  • No silent failures
  • Stack traces through error wrapping

No try/catch boilerplate:

  • Simple if err != nil checks
  • Natural flow of code
  • Error handling is part of happy path

Better error context:

  • Wrap errors with fmt.Errorf("%w", err)
  • Each layer adds context
  • Easy to trace error origin

Learning: After the initial resistance, explicit error handling makes code more reliable. You can't ignore errors.

Comparing Go to Node.js/TypeScript

After building the same types of applications in both ecosystems, here's my honest comparison:

AspectNode.js/TypeScriptGo
Learning CurveFamiliar for web devsSteeper initially
PerformanceGood for I/OExcellent for CPU & I/O
ConcurrencyEvent loop (single-threaded)Goroutines (true parallelism)
Type SafetyTypeScript (compile-time)Native (compile-time)
EcosystemMassive, but fragmentedSmaller, but cohesive
TestingMany options (Jest, Mocha)Standard library
Error Handlingtry/catchExplicit returns
DeploymentNode runtime requiredSingle binary
Best ForWeb APIs, serverlessMicroservices, CLI tools, DevOps

What I Wish I Knew Earlier

1. Don't fight Go's conventions

  • Embrace the standard library
  • Follow idiomatic patterns
  • Use go fmt and golangci-lint

2. Interfaces are small

  • Define interfaces where they're used, not where implemented
  • Keep interfaces minimal (1-3 methods)
  • Don't over-abstract

3. Context is everywhere

  • Learn context.Context early
  • Use it for cancellation, deadlines, values
  • Always first parameter in functions

4. Channels vs Mutexes

  • "Share memory by communicating" (channels)
  • Not "communicate by sharing memory" (mutexes)
  • Start simple, optimize later

5. Read the standard library

  • net/http is production-ready
  • database/sql interface is elegant
  • Learn from standard library patterns

Project Outcomes & Metrics

Time Investment:

  • ~2 weeks of focused learning
  • ~3 weeks of implementation
  • Countless hours debugging pointers 😅

Skills Gained:

  • Go language fundamentals
  • Advanced testing strategies
  • GraphQL backend implementation
  • Production-ready API structure
  • Database patterns in Go

Would I Use Go for My Next Project?

Absolutely, if:

  • ✅ Building microservices
  • ✅ Performance is critical
  • ✅ CLI tools or DevOps automation
  • ✅ High concurrency requirements
  • ✅ Long-running services

Probably not, if:

  • ❌ Rapid prototyping (Node.js is faster)
  • ❌ Heavy frontend integration (Next.js ecosystem)
  • ❌ Team has no Go experience
  • ❌ Need extensive 3rd party packages

Key Takeaways

  1. Go's simplicity is its strength - Small language, powerful standard library
  2. Testing is baked into the culture - Write tests naturally, not as an afterthought
  3. Explicit is better than implicit - Error handling, DI, type conversions
  4. Project structure matters - Follow conventions for maintainability
  5. Context is king - Master context.Context early
  6. Goroutines are magical - True concurrency without complexity
  7. Single binary deployment - No runtime dependencies, just ship

What's Next?

Having built this Todo backend, I'm eager to explore:

  • Microservices architecture with Go
  • gRPC for service-to-service communication
  • Kubernetes operators in Go
  • Performance optimization and profiling
  • Advanced concurrency patterns

Go has earned a permanent spot in my backend toolkit. While Node.js remains my go-to for rapid development and JavaScript-heavy projects, Go excels for performance-critical systems, CLI tools, and production-grade microservices.

Resources That Helped Me

Books:

  • "The Go Programming Language" by Donovan & Kernighan
  • "Effective Go" (official documentation)

Courses:

  • Boot.dev's "Learn Go" course
  • FreeCodeCamp Go tutorials

Documentation:

  • Official Go documentation (excellent!)
  • gqlgen documentation
  • Gin framework docs

Community:

  • r/golang on Reddit
  • Gopher Slack community
  • Go Time podcast

Learning a new language is challenging but rewarding. Go's philosophy of simplicity, explicit code, and built-in testing has made me a better backend engineer. If you're considering learning Go, build a real project—you'll learn far more than from tutorials alone.

GitHub Repository: Todo App Backend

Have questions about Go or want to discuss backend development? Feel free to reach out!