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
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
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)
}
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")
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);
}
}
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()
}
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()
}
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()
}
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"
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
}
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
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
}
})
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
},
})
})
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)
}
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)
}
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)),
)
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()
}
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:
- Basic HTTP service
- 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:
- This Post: Why I built it (you are here! 👋)
- Next: How dependency injection works in Go with Uber Fx
- Tutorial: Building your first microservice step-by-step
- Deep Dive: The interface abstraction pattern
- 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
I Want Your Feedback! 🙏
Before I release v1.0, I'm actively seeking feedback:
Questions for you:
- Does this approach resonate? Is it useful or overengineering?
- What modules would you want to see next?
- Any concerns with the interface abstraction pattern?
- 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
Top comments (0)