Part 2: Applying Component-Based Clean Architecture in Go
Package by Components
💡 WARNING: If you’re expecting perfect copy-paste code, better go to StackOverflow. This is real-world code with scars—60% worked on the first try, the other 40% made us cry during refactor.
🚧 The Project That Nearly Burned My Eyeballs
Several months ago, I inherited a Go monolith where:
users
andorders
shared structs like drunken confessions.A change in
User.Email
broke 3 different services.The tests were as fragile as Murano glass.
Today, I’ll show you how we turned it into this:
/cmd/
/internal/
users/
payments/
orders/
contracts/
web/
🔗 Step 1: The Contract – Our Technical “Tinder”
Instead of payments
stalking users
, we created an explicit contract:
// contracts/user/user_service.go
package user
type Service interface {
GetUserByID(id string) (UserDTO, error) // Just one method. Like a good microservice!
}
type UserDTO struct {
ID string // Just what we need
Email string // No "PasswordHash" here
}
Why is this magical?
payments
declares: “I need this.”users
replies: “I’ll give it to you, but don’t ask how.”Healthy decoupling: If tomorrow
users
switches from MySQL to CSV, nobody cares.
👷 Step 2: Implementing users
(Bugs Included)
Here’s the actual code we used (with the bug that made it to prod):
// internal/users/adapters/service.go
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUserByID(id string) (user.UserDTO, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return user.UserDTO{}, fmt.Errorf("couldn’t find user %s: %w", id, err)
// 💥 Bug: We didn’t log the error. Lesson learned later.
}
return user.UserDTO{
ID: u.ID,
Email: u.Email, // <- Is this valid? (Spoiler: Not always)
}, nil
}
Lesson learned:
Contracts don’t eliminate bugs, but they contain the damage. Even if UserService
failed:
payments
had no idea about internal fields likeu.FailedLoginAttempts
.The error was clear and manageable.
💸 Step 3: payments – The Demanding Client
Here’s how the contract is consumed (with a gotcha we missed):
// components/payments/payment_component_impl/service.go
type PaymentComponentImpl struct {
userService user.Service // <- Depends on the CONTRACT, not on users
}
func (s *PaymentComponentImpl) Charge(userID string, amount float64) error {
u, err := s.userService.GetUserByID(userID)
if err != nil {
return fmt.Errorf("payment failed for %s: %w", userID, err)
}
if u.Email == "" { // 💡 Validation where it SHOULD be!
return errors.New("email required for payments")
}
// ... Stripe/PayPal logic
}
What happened in production?
We discovered that some users had Email == ""
(bad initial validation).
Thanks to the contract: The bug was isolated in users
; payments
only saw invalid data, not corrupted fields.
⚡ Wiring in main.go – The “Marriage” of the Code
This is where everything comes together (or blows up, if done wrong):
// cmd/main.go
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg := &sync.WaitGroup{}
router := gin.Default()
container := app.NewAppContainer() // Creates all components, injecting respective databases
routes.Register(router, container) // Registers all routes
go server.Run(ctx, router, wg)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
cancel()
wg.Wait()
}
// Orchestrates component initialization and dependency injection
package app
type AppContainer struct {
OrderComponent orders.OrderComponent
PaymentComponent payments.PaymentComponent
UserComponent users.UserComponent
}
func NewAppContainer() *AppContainer {
return &AppContainer{
OrderComponent: orders.NewOrderComponentImpl(orders.Deps{
DB: db.InitMySQL(),
}),
PaymentComponent: payments.NewPaymentComponentImpl(payments.Deps{
DB: db.InitPostgres(),
}),
UserComponent: users.NewUserComponentImpl(users.Deps{
DB: db.InitMySQL(),
}),
}
}
// Orchestrates controllers
package routes
func Register(router *gin.Engine, container *app.AppContainer) {
api := router.Group("/api")
// Public (no auth)
public := api.Group("/public")
// Private (with auth middleware)
private := api.Group("/private")
private.Use(AuthMiddleware())
// Payments
paymentController := web.NewPaymentController(container.PaymentComponent)
paymentController.RegisterRoutes(private.Group("/payments"))
// Orders
orderController := web.NewOrderController(container.OrderComponent)
orderController.RegisterRoutes(public.Group("/orders"))
// Users
userController := web.NewUserController(container.UserComponent)
userController.RegisterRoutes(private.Group("/users"))
// Health
api.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
}
Key metaphor:
users/adapters/service.go
is like a professional translator.payments
says “I need a user,” and the translator converts that to the internal dialect ofusers
. Ifusers
changes its language, only the translator needs updating.
💣 What If We Don’t Use Contracts? (The Chaos We Lived Through)
Here’s a before/after from our real codebase:
🚫 Before (Criminal Coupling)
// internal/payments/payment_component_impl.go (OLD)
func (s *PaymentService) Refund(userID string) error {
u, err := s.userRepo.GetUser(userID) // Direct access to users repo!
if err != nil {
return err
}
if u.CreditCard == nil { // 💥 Internal field that changed 3 times
return errors.New("no credit card")
}
}
✅ After (Contracts to the Rescue)
// contracts/user/service.go (NEW)
type Service interface {
GetUserForPayment(id string) (PaymentUserDTO, error) // Now the contract is explicit!
}
// internal/payments/service.go (NEW)
func (s *PaymentService) Refund(userID string) error {
u, err := s.userService.GetUserForPayment(userID) // Only what’s NEEDED
// ...
}
Tangible benefits:
When
users
changed its credit card model,payments
didn’t even notice.payments
tests use a 10-line mock, not a fake DB.
📌 Conclusion: Less Theory, More Superpowers
This architecture allowed us to:
Move
users
to another repo without touchingpayments
.Refactor the DB 3 times without panic.
Onboard new devs with: “Need data from X? Check their contracts.”
“Clean Architecture is not about perfect folders, but about sleeping well knowing tomorrow’s change won’t ruin your week.”
💬 Have you also struggled with tight coupling? Tell me in the comments.
Conversación