Learning Go: Building a Production-Mocking Todo Backend from Scratch
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.
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
/internalcannot 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.gosits next toauth_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 != nilchecks - 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:
| Aspect | Node.js/TypeScript | Go |
|---|---|---|
| Learning Curve | Familiar for web devs | Steeper initially |
| Performance | Good for I/O | Excellent for CPU & I/O |
| Concurrency | Event loop (single-threaded) | Goroutines (true parallelism) |
| Type Safety | TypeScript (compile-time) | Native (compile-time) |
| Ecosystem | Massive, but fragmented | Smaller, but cohesive |
| Testing | Many options (Jest, Mocha) | Standard library |
| Error Handling | try/catch | Explicit returns |
| Deployment | Node runtime required | Single binary |
| Best For | Web APIs, serverless | Microservices, 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 fmtandgolangci-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.Contextearly - 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/httpis production-readydatabase/sqlinterface 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
- Go's simplicity is its strength - Small language, powerful standard library
- Testing is baked into the culture - Write tests naturally, not as an afterthought
- Explicit is better than implicit - Error handling, DI, type conversions
- Project structure matters - Follow conventions for maintainability
- Context is king - Master
context.Contextearly - Goroutines are magical - True concurrency without complexity
- 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!