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 (3)

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 😅