DEV Community

Cover image for Your Go 'Clean Architecture' Is Just Java in Disguise
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Go 'Clean Architecture' Is Just Java in Disguise


Open the average Go repo at a Series-B startup. The README claims "Clean Architecture." The internal/ folder has eight subdirectories. There are interfaces in front of every struct. The mappers/ package turns one struct into a nearly identical struct. The factories/ package returns pointers.

You did not write Go. You wrote Spring Boot. You removed the annotations and pretended that meant something.

This is the same hot take the people shipping Go in production keep making. Lucas de Athaides went after it in Why Clean Architecture Struggles in Golang. Serge Skoredin went harder in Hexagonal Architecture in Go: Why Your "Clean" Code Is Actually a Mess, calling the typical 10-folder layout "Java with worse syntax." Three Dots Labs has a whole anti-patterns tag on the theme. And Peter Bourgon's Go for Industrial Programming talk gave the community the rule most "Clean Go" repos still violate on line one.

If you have ever sat through a slow build because six layers of interfaces wrap a SELECT * FROM users WHERE id = $1, this post is for you.

The Layered-CRUD Trap

Here is the layout. You have seen it. You have probably written it. Maybe you are paid to maintain it right now.

internal/
├── controllers/
├── services/
├── repositories/
├── models/
├── dtos/
├── mappers/
├── validators/
├── factories/
Enter fullscreen mode Exit fullscreen mode

Eight folders. Every one of them imports the one below it. Every one of them defines an interface so the one above can mock it. Every one of them has a struct that holds a pointer to the one below it. Adding a single field to a User requires editing six files: the model, the DTO, the request DTO, the response DTO, the mapper from model to DTO, and the mapper from DTO to model.

A typical "create user" path in this kind of repo:

// controllers/user_controller.go
func (c *UserController) Create(w http.ResponseWriter, r *http.Request) {
    var req dtos.CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if err := c.validator.Validate(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    model := mappers.CreateUserRequestToModel(req)
    created, err := c.service.Create(r.Context(), model)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    resp := mappers.ModelToCreateUserResponse(created)
    json.NewEncoder(w).Encode(resp)
}
Enter fullscreen mode Exit fullscreen mode
// services/user_service.go
func (s *UserService) Create(ctx context.Context, m models.User) (models.User, error) {
    return s.repo.Create(ctx, m)
}
Enter fullscreen mode Exit fullscreen mode
// repositories/user_repository.go
func (r *UserRepository) Create(ctx context.Context, m models.User) (models.User, error) {
    _, err := r.db.ExecContext(ctx, "INSERT INTO users ...", m.Name, m.Email)
    return m, err
}
Enter fullscreen mode Exit fullscreen mode

What does the service do? It forwards. What do the mappers do? They copy fields between near-identical structs. What does the validator package do that go-playground/validator tags on the request could not? Nothing.

Now multiply this by 40 endpoints. Make a junior add a phone_number field. They edit half a dozen files and a test breaks because the mappers were not symmetric.

This is not architecture. This is ceremony.

Why This Imports Java's Complexity Tax

This layout did not arrive because someone read Uncle Bob and thought hard. It arrived because the senior on the team used to write Java, opened a fresh Go project, and reached for the only mental model they had.

In Java, the layered structure has a reason. Spring's runtime needs @Controller, @Service, and @Repository to wire beans. JPA hates it when you mix transactional services with HTTP boundaries. Lombok and MapStruct exist because the language cannot generate constructors and copy methods on its own. The DTO-mapper-validator stack pays a real tax the JVM charges.

Go does not charge that tax. No runtime container. Nothing scans your packages for stereotypes. No proxy class wraps your service for transactions. No reflection-based JSON binder demands a separate DTO with no methods on it.

Copy the Java layout into Go and you pay the tax for a service nobody provides. You get the boilerplate without the framework benefits. You write interface UserRepository in front of PostgresUserRepository because Spring needs proxies. Go does not need a proxy. The interface buys you a longer build and a confused junior reading three files to find the SQL.

Bourgon put the rule plainly: "interfaces are consumer contracts, not producer contracts." Define interfaces at the callsite, in the package that uses the dependency, never in the one that provides it. The Java instinct is the opposite. The producer ships an interface and a class that implements it, and every consumer imports both. Ported to Go, that gives you the eight-folder mess.

Three Dots Labs has documented the same anti-patterns: excessive interfaces, anemic models, premature DTOs, layers that exist to satisfy a diagram. None of these are Go problems. They are Java problems that followed engineers across the language boundary.

What Hexagonal Actually Gets Right in Go

Hexagonal architecture, taken seriously instead of as a folder-naming convention, is three things:

  1. A domain that knows nothing about the outside world.
  2. Ports (interfaces) the domain declares to describe what it needs.
  3. Adapters that implement those ports against real infrastructure.

That is it. No service layer. No DTO layer. No mapper. No factory. If your hexagonal Go repo has those, you brought them in from somewhere else.

In Go this works without ceremony, because of three language features:

Implicit interfaces. The Postgres adapter does not import the domain to declare implements UserRepository. It has the right methods. The compiler proves it satisfies the port at the wiring site. You can delete the interface declaration from the producer side entirely. Bourgon's rule: it does not belong there. The interface lives in the package that uses it, not the one that provides it.

// domain/user.go — the consumer declares the port it needs
type Users interface {
    Save(ctx context.Context, u User) error
    ByID(ctx context.Context, id string) (User, error)
}

type Service struct {
    users Users
}

func (s *Service) Register(ctx context.Context, email string) (User, error) {
    u := User{ID: newID(), Email: email}
    if err := s.users.Save(ctx, u); err != nil {
        return User{}, err
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode
// adapter/pg/users.go — the adapter never imports the interface
type Users struct {
    DB *sql.DB
}

func (u *Users) Save(ctx context.Context, x domain.User) error {
    _, err := u.DB.ExecContext(ctx,
        "INSERT INTO users(id, email) VALUES($1,$2)", x.ID, x.Email)
    return err
}

func (u *Users) ByID(ctx context.Context, id string) (domain.User, error) {
    var out domain.User
    err := u.DB.QueryRowContext(ctx,
        "SELECT id, email FROM users WHERE id=$1", id).
        Scan(&out.ID, &out.Email)
    return out, err
}
Enter fullscreen mode Exit fullscreen mode

The adapter struct is named Users. The port is named Users. They are not the same type. The compiler cares about method sets, not names. The wiring site picks one and hands it to the domain. No mapping. No factory. No DTO.

Vertical slices. Go's package model rewards organizing by feature, not by layer. internal/billing/ holds the billing domain, HTTP handlers, and Postgres adapter. internal/users/ holds the user equivalents. A bug report mentions billing. You open one folder. You do not jump across controllers/, services/, repositories/, mappers/ looking for the four files that touch one feature.

This is the pattern Lucas de Athaides recommends in his DEV piece. It is also what Bourgon means by "package user, yes; package models, no." A package named after a layer is a smell. A package named after a domain concept is what Go is built for.

No DTO factories. Go has anonymous structs and JSON tags. The request body decodes straight into a small struct that lives inside the handler file, three lines from the handler that uses it. No separate dtos/. No mappers/ translating between two structs with the same fields. The handler does the translation inline, in five lines, in the file where it matters.

// adapter/http/users.go
func (a *API) registerUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Email string `json:"email"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    u, err := a.users.Register(r.Context(), req.Email)
    if err != nil {
        http.Error(w, "register failed", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(struct {
        ID    string `json:"id"`
        Email string `json:"email"`
    }{u.ID, u.Email})
}
Enter fullscreen mode Exit fullscreen mode

That is the entire mapping layer. Two anonymous structs in one file. If the response shape diverges later, you grow it in place. You do not pre-pay for divergence that may never happen.

The Boring 3-Folder Layout That Beats It

Here is the layout I ship CRUD services with. It is boring on purpose.

internal/
├── domain/
│   ├── user.go        // entities, business rules, ports
│   └── service.go     // use cases on those entities
├── adapter/
│   ├── http/          // HTTP handlers, request/response shapes
│   └── pg/            // Postgres implementations of domain ports
└── cmd/
    └── server/main.go // wiring, the only place that knows everything
Enter fullscreen mode Exit fullscreen mode

Three top-level concepts. Domain. Adapter. Entry point. That is the whole architecture.

What is missing compared to the eight-folder version? Everything that was not pulling its weight. The controllers/ folder is gone because HTTP handlers are adapters and live in adapter/http/. The services/ folder is gone because the domain has services and they live in domain/ next to the entities they operate on. The repositories/ folder is gone because Postgres code is an adapter and lives in adapter/pg/. The models/ folder is gone because the domain types live in domain/, and naming a package models tells you nothing about what is in it.

The smaller-scoped folders go too:

  • No dtos/. Request and response structs live in the handler file that uses them.
  • No mappers/. Translation is three lines, written where the boundary actually crosses.
  • No validators/. Use struct tags or write a Validate() method on the request struct. You do not need a package.
  • No factories/. NewService(deps) is a function. It does not need a folder.

cmd/server/main.go is the composition root. Bourgon again: two natural boundary points are "between func main and the rest of your code, and along package APIs." Wiring lives there, and only there.

func main() {
    ctx := context.Background()

    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }

    users := &pg.Users{DB: db}
    svc := domain.NewService(users)
    api := httpadapter.New(svc)

    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", api.RegisterUser)
    mux.HandleFunc("GET /users/{id}", api.GetUser)

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
Enter fullscreen mode Exit fullscreen mode

Eight lines of wiring. No DI container. No reflection. You read main.go start to finish and understand the whole system in two minutes. A new engineer onboards in an afternoon, not a sprint.

Tests run in milliseconds because the domain has no infrastructure to mock. Hand the service a hand-written fake Users: a struct with a map in it. Assert on the result. No container. No mock framework.

type fakeUsers struct {
    rows map[string]domain.User
}

func (f *fakeUsers) Save(_ context.Context, u domain.User) error {
    f.rows[u.ID] = u
    return nil
}

func (f *fakeUsers) ByID(_ context.Context, id string) (domain.User, error) {
    u, ok := f.rows[id]
    if !ok {
        return domain.User{}, domain.ErrNotFound
    }
    return u, nil
}

func TestRegisterStoresUser(t *testing.T) {
    fake := &fakeUsers{rows: map[string]domain.User{}}
    svc := domain.NewService(fake)

    u, err := svc.Register(context.Background(), "a@b.com")
    if err != nil {
        t.Fatal(err)
    }
    if fake.rows[u.ID].Email != "a@b.com" {
        t.Fatalf("user not stored")
    }
}
Enter fullscreen mode Exit fullscreen mode

No mock library. No code generation. A real fake, written once. This is what de Athaides means when he says the Go community prefers "real implementations" to mock-heavy unit tests. The fake is a real implementation. It happens to live in memory.

When You Actually Do Need DTOs

DTOs and mappers are not always wrong. They are wrong by default. Add them when a real boundary forces them, not when a folder template suggests them.

The boundary that justifies a DTO is a versioned external contract that diverges from your domain. Three cases:

Public REST APIs with versioning. If you ship /v1/users and /v2/users with different shapes from the same entity, you have two response DTOs and a translation step. They live in adapter/http/v1/ and adapter/http/v2/, not in a top-level dtos/. The package split follows the boundary.

Cross-service messaging. A Kafka event another team consumes is a contract. It needs a versioned schema. It is not your domain entity. It is a message DTO inside adapter/kafka/. Changing the domain should not silently change the wire format.

Third-party API adapters. Stripe sends you a customer object. That is not your domain Customer. It is a Stripe DTO. It lives in adapter/stripe/ and translates inside that package. One function, func toDomain(s stripe.Customer) domain.Customer, in the file that calls Stripe.

The pattern: a DTO exists at every adapter that crosses a versioned external boundary, and lives inside that adapter package. Never in a top-level dtos/. Never in front of a domain entity nothing else consumes.

If your CRUD service has none of those boundaries, you do not need DTOs at all. One service owns the database. No public consumers outside your team. No events published. The handler decodes JSON into an anonymous struct. The domain handles the rest. A DTO layer with no external versioning pressure is insurance against a fire you cannot start.

The Smell Test

Open your Go repo. Run this:

  • Count top-level folders inside internal/. More than four — ask why.
  • Pick any one feature. Count files you touch to add a field. More than three — ask why.
  • Look for a mappers/ package. Ask what would break if you deleted it and inlined the work.
  • Find an interface declared in the same package as its only implementer. Ask which mock you ever wrote for it.
  • Read main.go. Can you explain the architecture in 60 seconds? If not, the wiring is hiding somewhere it should not be.

Most "Clean Architecture" Go repos fail every one of these. The fix is not another framework. Throw out the Java instincts. Keep the hexagonal core. Three folders, interfaces at the consumer, wiring in main. Stop there.


If you want the longer version with chapters on domain modeling, port design, transactions, observability, and migrating off a layered codebase, I wrote a book on it.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Thinking in Go is two books. The first teaches the language. The second, Hexagonal Architecture in Go, teaches you to architect with it without dragging Java patterns along for the ride.

Top comments (0)