DEV Community

Cover image for Why I Built Things-Kit: A Spring Boot Alternative for Go
Avicienna Ulhaq
Avicienna Ulhaq

Posted on

Why I Built Things-Kit: A Spring Boot Alternative for Go

I built Things-Kit, a modular microservice framework for Go that brings Spring Boot's developer experience while staying true to Go's philosophy. It's built on Uber Fx and provides composable modules for common infrastructure concerns.


My Journey with Microservices

Like many developers, I've built my share of microservices. Started with monoliths, moved to microservices, learned the hard way about distributed systems, and eventually found patterns that work.

But here's the thing: every time I started a new Go microservice, I found myself in the same frustrating cycle.


The Groundhog Day Problem

Picture this: It's Monday morning. You're starting a new microservice. You know exactly what you need to build, but first...

You need to set up the HTTP server:

router := gin.Default()
router.Use(middleware.Recovery())
router.Use(middleware.Logger())
// ... 20 more lines of setup
Enter fullscreen mode Exit fullscreen mode

Then wire up dependencies:

logger := zap.NewProduction()
defer logger.Sync()

db, err := sql.Open("postgres", connectionString)
if err != nil {
    logger.Fatal("failed to connect", zap.Error(err))
}
defer db.Close()

cache := redis.NewClient(&redis.Options{
    Addr: config.RedisAddr,
})
defer cache.Close()

// Create services
userService := service.NewUserService(logger, db, cache)
orderService := service.NewOrderService(logger, db, cache)

// Create handlers
userHandler := handler.NewUserHandler(userService)
orderHandler := handler.NewOrderHandler(orderService)

// Register routes
router.GET("/users/:id", userHandler.GetUser)
router.POST("/orders", orderHandler.CreateOrder)
// ... more routes
Enter fullscreen mode Exit fullscreen mode

Don't forget configuration:

viper.SetConfigName("config")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
    log.Fatal(err)
}

type Config struct {
    HTTPPort  int
    DBHost    string
    DBPort    int
    RedisAddr string
    LogLevel  string
    // ... 20 more fields
}

var config Config
if err := viper.Unmarshal(&config); err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

And of course, graceful shutdown:

srv := &http.Server{
    Addr:    fmt.Sprintf(":%d", config.HTTPPort),
    Handler: router,
}

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        logger.Fatal("server failed", zap.Error(err))
    }
}()

<-quit
logger.Info("shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
    logger.Fatal("server forced to shutdown", zap.Error(err))
}

logger.Info("server exited")
Enter fullscreen mode Exit fullscreen mode

By now, you've written 100+ lines of code and you haven't even started on your actual business logic!

Sound familiar? 😅


The Copy-Paste Trap

My first solution? "I'll just copy from my last project!"

But then:

  • Different projects used different patterns
  • Some had better error handling
  • Others had better testing setups
  • Configuration structures diverged
  • Updates to one project didn't propagate

I was maintaining the same infrastructure code in 15+ microservices, each slightly different. When I found a bug or wanted to improve something, I had to update it everywhere.

There had to be a better way.


Learning from Spring Boot

I come from a Java background (yes, I said it on a Go blog 😄). One thing Spring Boot does brilliantly is this:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. HTTP server? Check. Dependency injection? Check. Configuration? Check. Database connections? Check. Logging? Check.

But here's what I loved most: It wasn't magic. It was conventions over configuration with escape hatches everywhere. Need custom behavior? Implement an interface. Want a different database? Swap the dependency.

Could we bring this experience to Go without sacrificing Go's simplicity?


The Go Challenge

Go isn't Java. And that's great! Go's philosophy is different:

  • Simplicity over cleverness
  • Explicit over implicit
  • Composition over inheritance
  • Small, focused standard library

Any solution had to respect these principles. No magic. No reflection-heavy frameworks. No "enterprise" complexity.

But I still wanted:

  • ✅ Minimal boilerplate
  • ✅ Clear dependency injection
  • ✅ Testable code
  • ✅ Swappable components
  • ✅ Graceful lifecycle management

Enter Uber Fx

Then I discovered Uber Fx.

Fx is a dependency injection framework from Uber (yes, the ride-sharing company uses this in production). It's not magic - it's just a clever use of Go's type system and reflection to wire dependencies.

func main() {
    fx.New(
        fx.Provide(NewLogger),
        fx.Provide(NewDatabase),
        fx.Provide(NewUserService),
        fx.Invoke(RunServer),
    ).Run()
}
Enter fullscreen mode Exit fullscreen mode

This felt right! But you still had to:

  • Write all the New* functions
  • Handle lifecycle hooks manually
  • Set up configuration loading
  • Manage graceful shutdown
  • Create server instances

Every. Single. Time.


The Solution: Things-Kit

What if we could package these common patterns into reusable modules?

Here's what the same microservice looks like with Things-Kit:

package main

import (
    "github.com/things-kit/app"
    "github.com/things-kit/module/httpgin"
    "github.com/things-kit/module/logging"
    "github.com/things-kit/module/sqlc"
    "github.com/things-kit/module/viperconfig"
    "myapp/internal/user"
)

func main() {
    app.New(
        viperconfig.Module,  // Configuration
        logging.Module,      // Logging
        sqlc.Module,         // Database
        httpgin.Module,      // HTTP server
        user.Module,         // Your business logic
    ).Run()
}
Enter fullscreen mode Exit fullscreen mode

That's it.

No HTTP server setup. No manual wiring. No graceful shutdown code. No configuration loading boilerplate.

But unlike magic frameworks, you can see exactly what's happening. Each module is just an Fx module providing certain types:

// Inside the logging module
var Module = fx.Module("logging",
    fx.Provide(NewLogger),
)

func NewLogger(config Config) (log.Logger, error) {
    // Standard Zap logger setup
    return zap.NewProduction()
}
Enter fullscreen mode Exit fullscreen mode

Core Principles

Building Things-Kit, I stuck to five principles:

1. Modularity First

Every component is an independent Go module. Need just logging? Import just logging. Your binary only includes what you actually use.

import "github.com/things-kit/module/logging"
Enter fullscreen mode Exit fullscreen mode

Each module is versioned independently. No monolithic framework version hell.

2. Program to Interfaces

Your code depends on interfaces, not concrete implementations:

type UserHandler struct {
    logger log.Logger      // Not *zap.Logger
    db     *sql.DB         // Standard library
    cache  cache.Cache     // Not *redis.Client
}
Enter fullscreen mode Exit fullscreen mode

Want to swap Zap for Zerolog? Implement the log.Logger interface. Want to use Valkey instead of Redis? Implement the cache.Cache interface.

3. Convention over Configuration

Everything works out of the box with sensible defaults:

# config.yaml
http:
  port: 8080  # That's it for basic HTTP

logging:
  level: info  # Sensible default
Enter fullscreen mode Exit fullscreen mode

But every value is overridable when you need it.

4. No Magic

Every module is just regular Go code using Fx. You can read the source and understand exactly what's happening. No code generation. No build tags. No reflection tricks.

// You can always access the underlying types
fx.Invoke(func(logger log.Logger) {
    // This is the actual *zap.Logger if you need it
    if zapLogger, ok := logger.(*zap.Logger); ok {
        // Use Zap-specific features
    }
})
Enter fullscreen mode Exit fullscreen mode

5. Lifecycle Aware

All modules hook into Fx's lifecycle for graceful startup and shutdown:

fx.Invoke(func(lc fx.Lifecycle, server Server) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            // Start server
        },
        OnStop: func(ctx context.Context) error {
            // Graceful shutdown
        },
    })
})
Enter fullscreen mode Exit fullscreen mode

No signal handling. No goroutine management. It just works.


Show Me Real Code

Let's build a user service with Things-Kit:

1. Your Business Logic (internal/user/service.go):

package user

import "github.com/things-kit/module/log"

type Service struct {
    logger log.Logger
    repo   *Repository
}

func NewService(logger log.Logger, repo *Repository) *Service {
    return &Service{logger: logger, repo: repo}
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    s.logger.InfoC(ctx, "fetching user", log.Field{Key: "id", Value: id})
    return s.repo.FindByID(ctx, id)
}
Enter fullscreen mode Exit fullscreen mode

2. Your HTTP Handler (internal/user/handler.go):

package user

import (
    "github.com/gin-gonic/gin"
    "github.com/things-kit/module/http"
)

type Handler struct {
    service *Service
}

func NewHandler(service *Service) *Handler {
    return &Handler{service: service}
}

func (h *Handler) GetUser(c *gin.Context) {
    user, err := h.service.GetUser(c.Request.Context(), c.Param("id"))
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, user)
}

// Register routes
func (h *Handler) RegisterRoutes(router gin.IRouter) {
    router.GET("/users/:id", h.GetUser)
}
Enter fullscreen mode Exit fullscreen mode

3. Wire It Together (internal/user/module.go):

package user

import (
    "go.uber.org/fx"
    "github.com/things-kit/module/httpgin"
)

var Module = fx.Module("user",
    fx.Provide(NewRepository),
    fx.Provide(NewService),
    fx.Provide(NewHandler),
    fx.Invoke(httpgin.AsHandler((*Handler).RegisterRoutes)),
)
Enter fullscreen mode Exit fullscreen mode

4. Main (cmd/server/main.go):

package main

import (
    "github.com/things-kit/app"
    "github.com/things-kit/module/httpgin"
    "github.com/things-kit/module/logging"
    "github.com/things-kit/module/sqlc"
    "github.com/things-kit/module/viperconfig"
    "myapp/internal/user"
)

func main() {
    app.New(
        viperconfig.Module,
        logging.Module,
        sqlc.Module,
        httpgin.Module,
        user.Module,
    ).Run()
}
Enter fullscreen mode Exit fullscreen mode

That's a complete, production-ready microservice!


What's Different?

Before Things-Kit:

  • ❌ 150+ lines of infrastructure code
  • ❌ Manual dependency wiring
  • ❌ Custom graceful shutdown
  • ❌ Configuration boilerplate
  • ❌ Repeated across every service

After Things-Kit:

  • ✅ 10 lines in main
  • ✅ Automatic dependency injection
  • ✅ Built-in lifecycle management
  • ✅ Convention-based configuration
  • ✅ Reusable across all services

Current Status

Things-Kit is feature-complete for the initial release:

  • ✅ Core app runner
  • ✅ Configuration (Viper)
  • ✅ Logging (Zap)
  • ✅ HTTP server (Gin)
  • ✅ gRPC server
  • ✅ Database (sqlc/PostgreSQL)
  • ✅ Cache (Redis)
  • ✅ Messaging (Kafka)
  • ✅ Testing utilities

I've built two complete examples:

  1. Basic HTTP service
  2. Full CRUD API with PostgreSQL + integration tests

Both are running in production-like environments.


What's Next?

In this series, I'll dive deep into:

  1. This Post: Why I built it (you are here! 👋)
  2. Next: How dependency injection works in Go with Uber Fx
  3. Tutorial: Building your first microservice step-by-step
  4. Deep Dive: The interface abstraction pattern
  5. Production: Taking it from example to production

Try It Yourself

Want to see it in action?

git clone https://github.com/things-kit/things-kit-example.git
cd things-kit-example
cp config.example.yaml config.yaml
go run ./cmd/server

# In another terminal:
curl http://localhost:8080/health
curl http://localhost:8080/greet/DevTo
Enter fullscreen mode Exit fullscreen mode

I Want Your Feedback! 🙏

Before I release v1.0, I'm actively seeking feedback:

Questions for you:

  1. Does this approach resonate? Is it useful or overengineering?
  2. What modules would you want to see next?
  3. Any concerns with the interface abstraction pattern?
  4. How do you currently handle DI in your Go projects?

Links:

Drop a comment below! I read and respond to everything.

And if you found this interesting, ⭐ star the repo and follow along. Next week, I'll explain exactly how the dependency injection magic works under the hood.


Thank You! 🙌

Thanks for reading this far! Building Things-Kit has been a journey of learning about Go, microservices, and developer experience.

What's your biggest pain point when building Go microservices? Let me know in the comments!


Follow the series:

  • Part 1: Why I Built This (this post)
  • Part 2: Understanding Dependency Injection in Go (coming next week)
  • Part 3: Building Your First Microservice (tutorial)
  • Part 4: The Interface Abstraction Pattern
  • Part 5: From Example to Production

See you in Part 2! 👋


Built with ❤️ for the Go community

go #golang #microservices #opensource #webdev #programming #tutorial #showdev

Top comments (0)