Sponsored Content

DEV Community

Cover image for Go Doesn't Force Clean Architecture. That's Your Job.
Adam - The Developer ✨
Adam - The Developer ✨

Posted on

Go Doesn't Force Clean Architecture. That's Your Job.

Folders don't matter, dependencies do

The criticism of this is everywhere. Open any Go thread long enough and someone will show up to perform the same ritual:

"Go projects become messy. There's no framework to guide you. Nest, Django, Spring, they all tell you exactly where to put things. Go? It just says 'organize it somehow.'"

It's a fair criticism. Go is unusually permissive about structure. I just think blaming Go for a messy codebase is like blaming the empty document for the bad essay.

I don't think Go encourages bad architecture but rather it exposes it.


The Hell Is A Perfect Folder Structure??

Ask a hundred Go developers where to put business logic and you'll get a hundred answers (and 200 opinions).

  • "Should I use internal/?"
  • "Is everything supposed to live under pkg/?"
  • "Should I follow Clean Architecture?"
  • "What about the cmd/ directory?"

We spend so much time debating folder structures as if the arrangement of directories somehow determines code quality. As if renaming utils/ to pkg/shared/ is going to save us. God.

folders don't create architecture. Dependencies do.

You can meticulously organize your project like this:

my-app/
  cmd/main.go
  internal/
    handler/
    service/
    repository/
  pkg/domain/
  pkg/utils/
Enter fullscreen mode Exit fullscreen mode

And still write tightly coupled garbage. Handlers calling repositories directly. Services importing database drivers. Business logic mixed with HTTP concerns. Everything circular.

Beautiful folders, though. Very organized looking on GitHub.

There are better projects I've seen with just 5 packages, they just don't screenshot as well.


Architecture Is About Dependency Direction

The architecture is about making intentional decisions about how code depends on other code.

Have a look at this:

HTTP Handler
    ↓
Business Service
    ↓
Data Repository
Enter fullscreen mode Exit fullscreen mode

This isn't sacred because of folder names. It's valuable because of what it represents:

  • The handler only knows how to translate HTTP
  • The service only knows business rules
  • The repository only knows how to fetch data
  • Each layer depends on the layer below, never upward

That flow is intentional. If you reverse it, everything breaks:

Repository
    ↓
Service
    ↓
Handler
Enter fullscreen mode Exit fullscreen mode

Now the repository needs to know about HTTP? Nice, you've invented a database driver that also speaks REST and I think it's also a cry for help.

This flow works in a three-file project, a monolith, or a microservice with 50 packages. Go does not care how impressive your tree looks in the README.


Interfaces Belong to the Consumer

This is the part where people coming from Java have a small identity crisis.

In languages like Java, interfaces are typically defined alongside the implementation:

// repository package
public interface UserRepository {
    User find(String id);
    void save(User user);
    void delete(String id);
}

public class PostgresUserRepository implements UserRepository {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This feels natural. For me, it was the default. The repository defines the contract, the implementation fulfills it, everyone goes home happy. Except the consumer, who now depends on an abstraction it didn't ask for, including delete even though it only wanted find. Very generous. Very unhelpful.

Go flips this around:

// service package
type UserFinder interface {
    Find(id string) (*User, error)
}

type UserStorage interface {
    Save(user *User) error
}

type UserService struct {
    finder  UserFinder
    storage UserStorage
}
Enter fullscreen mode Exit fullscreen mode

The consumer defines exactly what it needs. The implementation simply satisfies those interfaces:

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) Find(id string) (*User, error) {
    // ...
}

func (r *PostgresUserRepository) Save(user *User) error {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The consumer owns the interface, not the implementation. Don't define an interface for what you provide; define one for what you need.

UserService doesn't care whether its dependencies are backed by Postgres, Redis, a file, or an API. It asked for Find and Save. That's the whole relationship. Very healthy, honestly.


Go Gives You Freedom

Both a feature and a burden.

Terrifying if you like to be told what to do. Freedom if you want to build thoughtfully. A trap if you thought "no framework" meant "no thinking."

The tradeoff is: frameworks prevent bad decisions by restricting your choices, or well, not really; you can still screw things up. The restriction is mostly psychological. Go makes you responsible for your choices, which is less comforting and more honest.

That means your team can't hide behind "the framework made us do it." You can't blame poor architecture on Rails conventions. If your Go project is a mess, it's because your team made it that way. There's no framework to pin it on. That's the whole feature.


Clean Architecture Isn't a Framework

A common misconception I see constantly: someone reads a Clean Architecture blog post, copies the folder tree into their repo, and waits for the cleanliness to arrive. It does not arrive.

cmd/
internal/
  application/
  domain/
  infrastructure/
  entity/
  repository/
  usecase/
pkg/
tests/
Enter fullscreen mode Exit fullscreen mode

Clean Architecture is about keeping business rules independent from implementation details. You can do that in three files:

cmd/main.go
internal/
  service.go
  postgres.go
pkg/models.go
Enter fullscreen mode Exit fullscreen mode

As long as service.go doesn't know about Postgres, business logic doesn't know about HTTP, and concrete implementations are swappable. That's it. You don't get extra architecture points for the folder named usecase.


Simplicity Doesn't Mean Lack of Discipline

Go lets you write less boilerplate but that doesn't mean less discipline. It means the discipline has to come from you, which is annoying, because boilerplate at least felt like progress, I know.

Clear package boundaries

Every package should have a single, defensible purpose. Importing a package should make semantic sense: import "user/service" says something. import "user/pkg1/internal/common/helpers" says you gave up and started a junk drawer.

Minimal public APIs

In Go, a capital letter exports. Think about what you export from each package. If you're exporting everything, you're not making choices. You're just shouting.

Dependency inversion where appropriate

This doesn't mean "use interfaces for everything." Premature abstraction is real. But when you have external dependencies (database, API, file system), invert them. Let the business logic define the interface the dependency must satisfy.

Small interfaces

The best interfaces in Go are two or three methods, max. An interface with ten methods is usually a hint that you're mixing concerns, or that you ported a Java interface and hoped nobody would notice.

Code reviews focused on design

Your standard code review checklist probably has: "tests?" "error handling?" "efficiency?" Add: "Does this dependency flow make sense? Is this the right abstraction?" Design is as important as correctness. Also easier to miss, because the tests still pass while the architecture quietly dies.


Closing

Architecture doesn't live in a programming language. It lives in the decisions engineers make.

Frameworks can enforce consistency. They can't enforce good judgment. Go just gives you fewer guardrails and assumes you'll use them.

Sometimes that pays off spectacularly. Sometimes it leaves you debugging a mess when you should be sleeping.

Either way, it's on you. That's not a bug in the language. That's the deal.

Top comments (9)

Collapse
 
sylwia-lask profile image
Sylwia Laskowska

I don’t write Go (apart from some experiments at home), but I can definitely confirm this from the frontend world. 😄

People have always said that Angular is a framework, so it almost guarantees good architecture. Well, after 10 years of working with it, I can confidently say that you can still spectacularly screw up your architecture if you really want to. 😂 Sloppy typing, putting everything into the main bundle, ignoring proper separation because someone either didn’t know better or simply couldn’t be bothered... and there you have a recipe for disaster.

Sure, having a framework is often more convenient because you don’t have to make every architectural decision yourself or spend time choosing libraries and solutions for everything. But a framework doesn’t magically give you good architecture. You can still make a beautiful mess with all the right tools.

Collapse
 
adamthedeveloper profile image
Adam - The Developer ✨

Exactly 😂 And I love Angular, btw.

I hardly touch it these days since moving into a more server-side role, but I'd never pass up an opportunity to work with it again. There's still so much to discover in that framework!

Collapse
 
sylwia-lask profile image
Sylwia Laskowska

True! And in a last couple of years a lot has changed and sometimes it's hard to catch up 😅

Collapse
 
heinrichneb profile image
Heinrich Neb

"The tests still pass while the architecture quietly dies" is the load-bearing sentence, and I think it undercuts the section it appears in.

Everything before it is right: folders aren't architecture, dependency direction is, and consumer-owned interfaces are the good Go instinct that people from Java have to unlearn. But the mechanism you propose for keeping it that way is a code review checklist item - and a checklist item is exactly the thing that fails the way you just described. Silently, gradually, with a green build.

The interesting part about your rule specifically is that it doesn't need discipline. Dependency direction is the one architectural property that's mechanically checkable, and Go makes it cheaper than in most languages, because imports are static, there's no runtime injection to chase, and the graph is one command away:

func TestHandlerNeverImportsPostgres(t *testing.T) {
    out, err := exec.Command("go", "list", "-deps", "./internal/handler/...").Output()
    if err != nil { t.Fatal(err) }
    deps := strings.Split(string(out), "\n")
    if len(deps) < 5 {
        t.Fatal("read almost no dependencies — the check is passing on an empty list")
    }
    for _, d := range deps {
        if strings.Contains(d, "/internal/repository") {
            t.Errorf("handler depends on repository: %s", d)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Fifteen lines, runs in the time it takes to list packages, and the arrow you drew in the post becomes a build failure instead of a review comment. internal/ already gives you one compiler-enforced boundary for free; this is the same idea applied to the direction rather than the visibility.

The len(deps) < 5 line is the part I'd argue for hardest, and it's the one everyone leaves out. A typo in the package path makes go list return almost nothing, every loop iterates over an empty slice, and the test goes green while checking absolutely nothing. That failure looks identical to a clean architecture from the outside - which is the exact phenomenon your closing sentence describes, one level up.

None of which contradicts the post. It's the same argument: Go doesn't give you the guardrail, so build it. I'd just rather build it once as a test than remember it at every review, because remembering is the part that decays.

Collapse
 
adamthedeveloper profile image
Adam - The Developer ✨

Wow, I genuinely never thought of doing this.

I spend quite a bit of time manually reviewing dependency injection and checking whether anything is flowing the wrong way, and somehow it never occurred to me to make the dependency direction itself mechanically enforceable.

This is genius. I especially love the point about the empty dependency list silently passing - the kind of failure mode I'd want the test to guard against!

Thanks so much for sharing this! Definitely stealing this haha

Collapse
 
marcusv4ne profile image
Marcus Vane

This is an excellent point, Heinrich.

"The tests still pass while the architecture quietly dies" is the exact failure mode of relying on human discipline during PR reviews. When a production deadline hits, review checklists get compromised, and the dependency graph quietly metastasizes while the build stays green.

The "len(deps) < 5" defensive guard is the real highlight of your snippet. Most custom architectural tests suffer from the "silent pass" vulnerability: a path typo makes "go list" return an empty slice, the loop never executes, and CI turns green over an unverified codebase.

Automating dependency direction as an Architectural Fitness Function that breaks the build is the only reliable way to enforce structural invariants over multi-year codebases.

Solid approach.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

On the escape-analysis branch of this thread: the interface itself is not what moves the struct to the heap, visibility of the concrete type at the call site is. On Go 1.26.2 darwin/arm64 under -gcflags=-m, assigning a *PG to a local Store and calling through it prints devirtualizing s.Get to *PG and &PG{...} does not escape; handing that same value to a go:noinline function whose parameter is Store prints escapes to heap, while the identical call with a *PG parameter does not. So the property worth protecting is not method count but the boundary where devirtualization still works, since once the value crosses into a call the compiler cannot inline or devirtualize, the allocation happens no matter how small the interface is.

Collapse
 
marcusv4ne profile image
Marcus Vane

Great write-up, Adam.

The "Cargo Cult of Clean Architecture" has produced more unmaintainable, deeply nested folder trees than actually decoupled systems. The realization that interfaces belong to the consumer is the exact inflection point where a developer transitions from OOP muscle memory to idiomatic Go.

There is an additional, physical dimension to this dependency discipline that often gets overlooked: Go's Escape Analysis and Garbage Collection pressure.

When developers prematurely introduce large, Java-style interface hierarchies across multiple packages to make their architecture look "clean", they often inadvertently force concrete structs to escape from the Stack to the Heap. Dynamic dispatch through an "iface" container prevents the compiler from proving stack lifetime.

Keeping packages small, boundaries explicit, and consumer-defined interfaces minimal (1–2 methods max) doesn't just keep the dependency graph acyclic. It allows Go's escape analysis to keep allocations on the goroutine stack, completely bypassing the Garbage Collector on high-throughput paths.

Clean architecture in Go isn't just about code organization; it's about compiler symbiosis.

Collapse
 
adamthedeveloper profile image
Adam - The Developer ✨

Interesting angle! I honestly wasn't thinking about the compiler side of it when I wrote this.

"Compiler symbiosis" has a nice touch, makes me wonder how often we carry Java-style abstractions into Go that are technically clean but give the compiler less room to optimize.

thanks for sharing!!