The Architecture Pattern That Actually Works in Golang (Hint: It’s Not Clean Architecture)
Go scales through clarity, not framework-style layering. Domain-centric packages beat onion diagrams every time.
Clean Architecture is supposed to be the holy grail of software design.
If you read tech Twitter, watch conference talks, or join a new enterprise team, you’ve probably seen this diagram:
Controllers
Use cases
Repositories
Entities
Ports & Adapters
Dependencies flowing inward
It sounds perfect.
It promises:
separation of concerns
testability
maintainability
framework independence
So when engineers move into Go — especially from Java/Spring/C# — they naturally ask:
“How do we implement Clean Architecture in Go?”
And at first…
It feels right.
Until one day you open your Go service…
…and you can’t find where the actual logic lives anymore.
Let’s talk about why Clean Architecture often makes Go worse.
The Problem Isn’t Clean Architecture
Let’s be clear:
Clean Architecture isn’t evil.
The problem is this:
Clean Architecture was born in ecosystems that need heavy abstraction.
Go was born in ecosystems that reject it.
Go doesn’t scale through frameworks.
Go scales through clarity.
And when you import architecture patterns designed for Java/C# into Go…
You often end up writing Java/C#…
in Go syntax.
The First Symptom: Layers Without Meaning
In Clean Architecture, a simple operation becomes a relay race.
Example: “Get User by ID”
In a typical layered Go service:
HTTP Handler
↓
Service Layer
↓
UseCase Interface
↓
Repository Interface
↓
Repository Implementation
↓
DatabaseThat’s not architecture.
That’s indirection.
What It Looks Like in Code
Step 1: Repository Interface
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}Step 2: Repository Implementation
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id=$1", id,
).Scan(&user.ID, &user.Name)
return &user, err
}Step 3: Service Layer
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.FindByID(ctx, id)
}Step 4: Handler Layer
func (h *Handler) GetUser(c *fiber.Ctx) error {
id := c.Params("id")
user, err := h.service.GetUser(c.Context(), id)
if err != nil {
return err
}
return c.JSON(user)
}Now pause.
Ask yourself honestly:
What value did the service layer add?
Nothing.
It forwarded the call.
This is what happens in most Clean Architecture Go projects:
Layers exist because the architecture demands them…
not because the system needs them.
The Architecture Tax
Every feature now requires:
handler
DTO
usecase interface
implementation
repository interface
repository implementation
mocks
wiring
A one-line SQL change becomes a 6-file diff.
This is not maintainability.
This is ceremony.
Interfaces Everywhere = Unidiomatic Go
Clean Architecture pushes interface-first design:
type UserUseCase interface {
Execute(ctx context.Context, req Request) (*Response, error)
}But Go’s philosophy is different:
Accept interfaces, return structs.
In Go, interfaces are meant to be:
small
consumer-defined
introduced only when needed
Not global contracts imposed upfront.
When you define interfaces for everything, you get:
mock-heavy testing
abstraction without purpose
code nobody wants to touch
You recreate Spring patterns…
without Spring.
Testing Becomes Worse, Not Better
Clean Architecture claims to improve testability.
In Go, it often does the opposite.
Instead of testing real code, teams test mocks of mocks:
mockRepo.On("FindByID").Return(&User{}, nil)Now your test verifies:
the mock was called
not the system works
Go’s testing culture is simpler:
fakes
table-driven tests
minimal mocking
Clean Architecture often pulls you away from that.
The Repository Pattern: The Biggest Java/C# Carryover Mistake
Repositories exist in Java/C# because:
ORMs are complex
mocking is expected
interfaces are explicit contracts
In Go?
Most repositories are just SQL wrappers.
They add no abstraction.
Just boilerplate.
Go doesn’t need:
UserRepository
UserRepositoryImpl
UserRepositoryMock
UserRepositoryFactoryIt needs:
store.GetUserByID()The Go Alternative: Domain-Centric Architecture
Here’s what actually works in Go:
Organize by domain capability, not technical layers.
Instead of:
handler/
service/
repository/Do:
internal/user/
internal/billing/
internal/campaign/Each domain owns:
its data
its persistence
its logic
its API boundary
What That Looks Like
Folder Structure
internal/user/
handler.go
service.go
store.go
model.goNow everything related to “user” lives together.
No jumping across folders.
No scattered ownership.
Idiomatic Go Example
user/store.go
package user
type Store struct {
db *sql.DB
}
func (s *Store) GetByID(ctx context.Context, id string) (*User, error) {
var u User
err := s.db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id=$1", id,
).Scan(&u.ID, &u.Name)
return &u, err
}user/service.go
package user
func GetProfile(ctx context.Context, store *Store, id string) (*User, error) {
return store.GetByID(ctx, id)
}user/handler.go
package user
type Handler struct {
store *Store
}
func (h *Handler) GetUser(c *fiber.Ctx) error {
id := c.Params("id")
u, err := GetProfile(c.Context(), h.store, id)
if err != nil {
return err
}
return c.JSON(u)
}No repository interface.
No forwarding layers.
Just Go.
Interfaces Still Exist — But Only Where Needed
When substitution is real, Go does interfaces beautifully.
Consumer defines:
type UserFetcher interface {
GetByID(ctx context.Context, id string) (*User, error)
}You can use this at service layer or in test fake.
Test fake:
type FakeStore struct{}
func (f *FakeStore) GetByID(ctx context.Context, id string) (*User, error) {
return &User{ID: id, Name: "Test"}, nil
}No mocking frameworks.
No ceremony.
Just behavior.
The Real Go Architecture Pattern
If you remember one thing:
Go architecture is package boundaries + consumer-defined interfaces.
Not onion diagrams.
Not ports/adapters folders.
Just:
cohesive packages
explicit dependencies
minimal abstraction
concrete-first design
That’s how Kubernetes is built.
That’s how Go survives at scale.
When Clean Architecture Does Make Sense
To be fair, Clean Architecture helps when:
domain is extremely complex
multiple backends must be swapped
you’re building a reusable platform library
But for most Go microservices?
It’s overkill.
Bottom Line
Clean Architecture isn’t wrong — it’s just often misapplied.
In ecosystems like Java/C# and Spring, heavy layering protects you from frameworks, inheritance, and enterprise sprawl.
But Go doesn’t have that problem.
Go’s strength is precision:
packages with clear ownership
interfaces that emerge from consumers
concrete code before abstraction
domain logic that stays close to where it matters
When you force Go into onion layers, you don’t get cleanliness.
You get distance.
You get ceremony.
You get a codebase where the business logic is always hiding behind one more interface.
The best Go architecture isn’t a diagram.
It’s a system that stays readable at 10K lines… and still readable at 500K.
The real lesson:
Don’t build architecture.
Build software.
Let structure emerge.
If you enjoyed this deep dive…
I write weekly about:
Go performance and runtime behavior
Kubernetes-native service design
Expert-level engineering lessons from real systems
Subscribe if you want more posts like this.


