Clean architecture in Go: how far does it make sense to apply it

Clean architecture and hexagonal in Go: layers, interfaces, trade-offs and when architecture helps vs when it kills Go's simplicity.

Cover for Clean architecture in Go: how far does it make sense to apply it

My first Go project looked like a Spring Boot application without Spring. Four layers, interfaces everywhere, a dependency injection container and nested folders that needed five levels of depth to reach the handler. It compiled fast, sure. But navigating the code was a nightmare. Every minor change required touching three files and two interfaces. I had brought my Java/Spring habits to a language that was designed with a radically different philosophy.

The problem wasn’t clean architecture itself. It was that I was applying it without questioning whether each layer, each abstraction and each interface made sense in the context of Go. And they didn’t.

Since then I’ve rewritten projects, worked with teams that make the same mistake, and reached a point where I think I have a pretty clear idea of when architecture helps and when it kills exactly what makes Go attractive. Although, honestly, I’m still discovering nuances.


What clean architecture is (without the textbook definition)

Robert C. Martin formalized it. The concept is simple: separate software into concentric layers where dependencies always point inward, toward the business rules. The outer layer (frameworks, databases, HTTP) depends on the inner one (use cases, entities), never the other way around.

Alistair Cockburn’s hexagonal architecture (ports and adapters) arrives at the same place by a different route: your business logic defines ports (interfaces) and the outside world provides adapters that implement them. Database, HTTP API, message queue… are interchangeable adapters.

In practice, both converge on the same thing:

  • Entities / domain: pure business rules.
  • Use cases / application: the orchestration of those rules.
  • Adapters / infrastructure: the concrete implementation of persistence, HTTP, etc.
  • Frameworks / drivers: the glue with the outside world.

In Java or C# this makes sense because the languages push you in that direction. You have a heavy framework (Spring, ASP.NET) that you need to isolate. You have a dependency injection container that resolves complex object graphs. You have explicit inheritance and polymorphism that make interfaces cheap to maintain.

Go doesn’t work that way. And that’s the friction point.


Why people bring clean architecture to Go

The short answer: because we come from Java, C# or some language with heavy frameworks. And it’s not bad to have that experience, quite the opposite. The problem is something else.

When you’ve been working with Spring Boot for years, your brain has a mold. Controller, service, repository, DTO, mapper, interface for each dependency, DI container. You do it on autopilot. When you start with Go, the first impulse is to replicate that mold. And Go lets you, because it’s flexible enough. But just because you can doesn’t mean you should.

I’ve seen Go projects with this structure:

project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/
│   │   ├── entity/
│   │   │   └── user.go
│   │   ├── repository/
│   │   │   └── user_repository.go     // interface
│   │   └── service/
│   │       └── user_service.go        // interface
│   ├── application/
│   │   └── usecase/
│   │       └── create_user.go
│   ├── infrastructure/
│   │   ├── persistence/
│   │   │   └── postgres_user_repo.go
│   │   ├── http/
│   │   │   └── user_handler.go
│   │   └── di/
│   │       └── container.go
│   └── adapter/
│       └── dto/
│           └── user_dto.go
└── pkg/

If you come from Spring Boot, this looks familiar. If you’ve been with Go for a while, it probably triggers a visceral reaction. Not because the separation is inherently bad, but because the granularity is excessive for what Go needs.

A handler that calls a use case that calls a service that calls a repository… to save a user in PostgreSQL. Four levels of indirection. In Go, where code navigation is one of the best experiences out there thanks to gopls, you end up jumping between files without gaining anything in return.


What Go already gives you: packages as boundaries

But before adding layers, I think it’s worth pausing to understand what organizational mechanisms Go provides out of the box. Because sometimes the answer is already there.

Packages as module boundaries

In Go, a package is a real boundary. What starts with a capital letter is public; what doesn’t is private to the package. You don’t need interfaces to hide implementation. The package visibility system already does that.

// internal/user/store.go
package user

// store is private to the package. No one outside can use it directly.
type store struct {
    db *sql.DB
}

func (s *store) Create(ctx context.Context, u User) error {
    _, err := s.db.ExecContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2)",
        u.Name, u.Email,
    )
    return err
}

The user package exposes what it decides to expose. You don’t need a UserRepository interface in another package to achieve encapsulation. The package is the boundary.

Implicit interfaces

And here is the fundamental difference from Java or C#. In Go, an interface is satisfied implicitly. You don’t declare implements. If your struct has the methods, it satisfies the interface. Period.

This changes, in a way that isn’t immediately obvious, where and when you define interfaces. In Java you define the interface where the implementation lives (or in the domain module). In Go, the idiomatic convention is to define the interface where it is consumed, not where it is implemented.

// internal/order/service.go
package order

// The order service needs to look up users.
// Define the interface here, where it is consumed.
type UserFinder interface {
    FindByID(ctx context.Context, id string) (User, error)
}

type Service struct {
    users UserFinder
}

The user package doesn’t even know this interface exists. It simply has a FindByID method on some of its structs, and that’s enough. This is the Go way of doing things, and it’s much more powerful than Java’s explicit model because it reduces coupling to zero: the consumer defines what it needs, the producer doesn’t need to know who consumes it.

If you come from interfaces in Go, you already have this clear. If not, it’s worth digging deeper because it completely changes how you design architecture.


A pragmatic architecture for Go: handlers, services, repositories

After several projects, I’ve been converging on a structure that, at least in my experience, works for most APIs and services. It has no elegant name. It’s simply the minimum that keeps the code organized without adding unnecessary layers.

project/
├── cmd/
│   └── server/
│       └── main.go          // composition, wiring, startup
├── internal/
│   ├── user/
│   │   ├── handler.go       // HTTP handlers
│   │   ├── service.go       // business logic
│   │   ├── store.go         // data access
│   │   └── user.go          // types and models
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── store.go
│   │   └── order.go
│   └── platform/
│       ├── database/
│       │   └── postgres.go  // DB connection
│       └── server/
│           └── http.go       // HTTP server setup
└── go.mod

Three levels of responsibility per domain. Not four. Not five. Three:

Handler: receives the HTTP request, validates input, calls the service, returns response. Doesn’t know the database.

Service: business logic. Orchestrates operations, applies rules, coordinates between stores if needed. Doesn’t know HTTP.

Store: data access. SQL, Redis, calls to external APIs. Doesn’t know business logic.

// internal/user/handler.go
package user

import (
    "encoding/json"
    "net/http"
)

type Handler struct {
    svc *Service
}

func NewHandler(svc *Service) *Handler {
    return &Handler{svc: svc}
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    u, err := h.svc.Create(r.Context(), req.Name, req.Email)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(u)
}
// internal/user/service.go
package user

import (
    "context"
    "fmt"
)

type Service struct {
    store *Store
}

func NewService(store *Store) *Service {
    return &Service{store: store}
}

func (s *Service) Create(ctx context.Context, name, email string) (User, error) {
    if name == "" {
        return User{}, fmt.Errorf("name is required")
    }

    existing, err := s.store.FindByEmail(ctx, email)
    if err != nil {
        return User{}, fmt.Errorf("checking existing user: %w", err)
    }
    if existing != nil {
        return User{}, fmt.Errorf("email already registered")
    }

    return s.store.Create(ctx, User{Name: name, Email: email})
}
// internal/user/store.go
package user

import (
    "context"
    "database/sql"
)

type Store struct {
    db *sql.DB
}

func NewStore(db *sql.DB) *Store {
    return &Store{db: db}
}

func (s *Store) Create(ctx context.Context, u User) (User, error) {
    err := s.db.QueryRowContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, created_at",
        u.Name, u.Email,
    ).Scan(&u.ID, &u.CreatedAt)
    return u, err
}

func (s *Store) FindByEmail(ctx context.Context, email string) (*User, error) {
    var u User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, name, email, created_at FROM users WHERE email = $1",
        email,
    ).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return &u, err
}

Notice: there are no interfaces yet. The Service depends directly on the concrete Store. The Handler depends directly on the concrete Service. And that’s fine for most projects.

For more details on how to organize this at the folder level, I have a dedicated article on Go project structure.


When to add layers: the complexity threshold

So, if the simple structure works, the natural question is: when does it make sense to add layers and abstractions? The rule I’ve been distilling is pretty straightforward: when the pain is real, not anticipated.

Signs that you need more structure

You need to mock external dependencies in tests. If your service calls a store directly that depends on PostgreSQL, and you want fast unit tests without a database, you need an interface.

// internal/order/service.go
package order

import "context"

// Now yes, we define interfaces. Because we need them for testing.
type ProductStore interface {
    FindByID(ctx context.Context, id string) (Product, error)
    UpdateStock(ctx context.Context, id string, delta int) error
}

type PaymentGateway interface {
    Charge(ctx context.Context, amount Money, method PaymentMethod) (PaymentResult, error)
}

type Service struct {
    products ProductStore
    payments PaymentGateway
}

You have multiple real implementations. If your notification service can send by email, SMS or push, that abstraction has real justification, not theoretical.

Different teams work on different layers. If one team maintains the business logic and another the infrastructure integration, formal separation helps define contracts.

The domain is complex. If you have business rules with invariants, states, transitions and complex validations, isolating the domain from the infrastructure is worth it.

Signs that you’re over-architecting

  • You have interfaces with a single implementation that you don’t use in tests.
  • You need mappers between DTOs, domain models and persistence entities that are basically the same struct.
  • Changing a field in the database requires touching more than three files.
  • New team members take more than a day to understand where each thing goes.

If you identify more with the second group, it’s probably worth simplifying. If you identify more with the first, add structure. The answer doesn’t have to be the same for every project.


Interfaces in Go architecture: define them where they are consumed

And this is where I see the most mistakes in Go projects that try to do clean architecture. People who define interfaces in the domain package, as they would in Java:

// ❌ Anti-pattern: interfaces in the domain package
// internal/domain/repository/user.go
package repository

type UserRepository interface {
    Create(ctx context.Context, u entity.User) error
    FindByID(ctx context.Context, id string) (entity.User, error)
    FindByEmail(ctx context.Context, email string) (entity.User, error)
    Update(ctx context.Context, u entity.User) error
    Delete(ctx context.Context, id string) error
}

This is Java with Go syntax. The interface is huge, has every possible method, and lives in a package that doesn’t use it. Every consumer has to import that interface even if they only need one method.

The Go way:

// ✅ Interface in the consumer, minimal
// internal/notification/service.go
package notification

type UserEmailFinder interface {
    FindEmail(ctx context.Context, userID string) (string, error)
}

type Service struct {
    users UserEmailFinder
    // ...
}

The notification service doesn’t need to create, update or delete users. It only needs to find an email. It defines an interface with a single method. This is the interface segregation principle taken to its ultimate expression, and in Go it comes naturally thanks to implicit interfaces.

Small interfaces, composed if needed

If a service needs more methods, you can compose:

type UserReader interface {
    FindByID(ctx context.Context, id string) (User, error)
}

type UserWriter interface {
    Create(ctx context.Context, u User) (User, error)
    Update(ctx context.Context, u User) error
}

// Only if someone needs both
type UserStore interface {
    UserReader
    UserWriter
}

Go’s standard library is full of examples: io.Reader, io.Writer, io.ReadWriter. Interfaces of one or two methods that compose. That is the model to follow.


Dependency injection without a container

In Spring you have @Autowired or constructor injection with a container that resolves the entire graph. In Go you don’t need any of that. Your main.go is your DI container.

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    "myapp/internal/order"
    "myapp/internal/platform/database"
    "myapp/internal/user"
)

func main() {
    // Infrastructure
    db, err := database.Connect("postgres://localhost/myapp?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Stores
    userStore := user.NewStore(db)
    orderStore := order.NewStore(db)

    // Services
    userService := user.NewService(userStore)
    orderService := order.NewService(orderStore, userService)

    // Handlers
    userHandler := user.NewHandler(userService)
    orderHandler := order.NewHandler(orderService)

    // Router
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", userHandler.Create)
    mux.HandleFunc("GET /users/{id}", userHandler.Get)
    mux.HandleFunc("POST /orders", orderHandler.Create)
    mux.HandleFunc("GET /orders/{id}", orderHandler.Get)

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

All the wiring is explicit. You can read main.go and see exactly what depends on what. No magic. No reflection. No container resolving dependencies at runtime.

If the dependency graph becomes complex, the most I do is extract setup functions:

func setupUserDomain(db *sql.DB) *user.Handler {
    store := user.NewStore(db)
    svc := user.NewService(store)
    return user.NewHandler(svc)
}

func setupOrderDomain(db *sql.DB, userSvc *user.Service) *order.Handler {
    store := order.NewStore(db)
    svc := order.NewService(store, userSvc)
    return order.NewHandler(svc)
}

There are libraries like Google’s wire or Uber’s fx for dependency injection in Go. I’ve tried them. For most projects, the explicit main.go is better. When main.go becomes unmanageable (more than 200 lines of wiring), it may make sense to introduce one of these tools. But that threshold arrives much later than people think.


The over-engineering trap: too many abstractions kill Go’s simplicity

I think the biggest problem I see in Go projects is not the lack of architecture. It’s the excess. And I say this having fallen into that trap myself.

The real cost of each abstraction

Every interface you add is an indirection that someone has to follow when reading the code. Every layer is a jump between files. Every mapper is a place where you can have conversion bugs. Every additional package is one more import to manage.

In Java, the IDE hides much of this cost. IntelliJ generates implementations, navigates through interfaces, autocompletes mocks. The cost of abstractions is partially subsidized by tooling.

In Go, although gopls is excellent, the philosophy of the language is that code be directly readable. go doc shows what’s there. grep works to find usages. Adding unnecessary layers of abstraction breaks this experience.

An example of what NOT to do

I’ve seen this in a real project (names changed):

// internal/domain/entity/task.go
type Task struct {
    ID          string
    Title       string
    Description string
    Status      TaskStatus
    CreatedAt   time.Time
}

// internal/domain/repository/task_repository.go
type TaskRepository interface {
    Save(ctx context.Context, task entity.Task) error
    FindByID(ctx context.Context, id string) (entity.Task, error)
}

// internal/application/dto/task_dto.go
type CreateTaskDTO struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

type TaskResponseDTO struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Status      string `json:"status"`
    CreatedAt   string `json:"created_at"`
}

// internal/application/mapper/task_mapper.go
func ToEntity(dto CreateTaskDTO) entity.Task {
    return entity.Task{
        Title:       dto.Title,
        Description: dto.Description,
        Status:      entity.StatusPending,
    }
}

func ToDTO(task entity.Task) TaskResponseDTO {
    return TaskResponseDTO{
        ID:          task.ID,
        Title:       task.Title,
        Description: task.Description,
        Status:      string(task.Status),
        CreatedAt:   task.CreatedAt.Format(time.RFC3339),
    }
}

// internal/application/usecase/create_task.go
type CreateTaskUseCase struct {
    repo repository.TaskRepository
}

func (uc *CreateTaskUseCase) Execute(ctx context.Context, dto CreateTaskDTO) (TaskResponseDTO, error) {
    task := mapper.ToEntity(dto)
    if err := uc.repo.Save(ctx, task); err != nil {
        return TaskResponseDTO{}, err
    }
    return mapper.ToDTO(task), nil
}

// internal/infrastructure/persistence/postgres_task_repo.go
type PostgresTaskRepo struct {
    db *sql.DB
}

func (r *PostgresTaskRepo) Save(ctx context.Context, task entity.Task) error {
    // ... SQL
}

// internal/infrastructure/http/task_handler.go
type TaskHandler struct {
    createTask *usecase.CreateTaskUseCase
}

Seven files. Four packages. A mapper that copies fields from one struct to another that is almost identical. A use case that is a one-line wrapper over a repository. And in the end, all it does is save a task to PostgreSQL. Technically the person who wrote it wasn’t wrong. But the navigation cost was disproportionate to what it did.


The same thing, in a pragmatic version

// internal/task/task.go
package task

import "time"

type Task struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Status      Status    `json:"status"`
    CreatedAt   time.Time `json:"created_at"`
}

type Status string

const (
    StatusPending  Status = "pending"
    StatusDone     Status = "done"
)

type CreateRequest struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}
// internal/task/store.go
package task

import (
    "context"
    "database/sql"
    "time"

    "github.com/google/uuid"
)

type Store struct {
    db *sql.DB
}

func NewStore(db *sql.DB) *Store {
    return &Store{db: db}
}

func (s *Store) Create(ctx context.Context, title, description string) (Task, error) {
    t := Task{
        ID:          uuid.NewString(),
        Title:       title,
        Description: description,
        Status:      StatusPending,
        CreatedAt:   time.Now(),
    }

    _, err := s.db.ExecContext(ctx,
        "INSERT INTO tasks (id, title, description, status, created_at) VALUES ($1, $2, $3, $4, $5)",
        t.ID, t.Title, t.Description, t.Status, t.CreatedAt,
    )
    return t, err
}
// internal/task/handler.go
package task

import (
    "encoding/json"
    "net/http"
)

type Handler struct {
    store *Store
}

func NewHandler(store *Store) *Handler {
    return &Handler{store: store}
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    if req.Title == "" {
        http.Error(w, "title is required", http.StatusBadRequest)
        return
    }

    t, err := h.store.Create(r.Context(), req.Title, req.Description)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(t)
}

Three files. One package. No interfaces, no mappers, no empty use cases. Does exactly the same thing. If tomorrow I need unit tests of the handler without a database, then I extract an interface from the store. Not before.

Is it architecturally less “correct”? Depends on who you ask. I think it’s more correct for Go, because it respects the philosophy of the language: clarity, simplicity, the minimum necessary. But I understand that someone with a different background might see it differently.


When full clean architecture does make sense

I don’t want this article to be read as “never do clean architecture in Go”. That would be too simplistic. There are contexts where it makes complete sense:

Complex domains with rich business rules

If you’re building a billing system with tax rules from multiple countries, invoice states with controlled transitions, complex business validations and financial calculations that cannot have bugs… isolating the domain from the framework and the database is an investment that pays off.

// internal/domain/invoice/invoice.go
package invoice

import "errors"

type Invoice struct {
    id        string
    lines     []Line
    status    Status
    taxRules  TaxRuleSet
}

// Pure domain logic, no infrastructure dependencies
func (i *Invoice) AddLine(product string, quantity int, unitPrice Money) error {
    if i.status != StatusDraft {
        return errors.New("cannot modify a non-draft invoice")
    }
    if quantity <= 0 {
        return errors.New("quantity must be positive")
    }

    line := Line{
        Product:   product,
        Quantity:  quantity,
        UnitPrice: unitPrice,
        Tax:       i.taxRules.Calculate(product, unitPrice),
    }
    i.lines = append(i.lines, line)
    return nil
}

func (i *Invoice) Finalize() error {
    if i.status != StatusDraft {
        return errors.New("can only finalize draft invoices")
    }
    if len(i.lines) == 0 {
        return errors.New("cannot finalize empty invoice")
    }
    i.status = StatusFinalized
    return nil
}

Here the domain separation lets you test all the tax logic without a database or HTTP. That’s worth its weight in gold.

Large teams with clear boundaries

With 15-20 developers working on the same service, Go’s package conventions aren’t enough to organize the work. You need formal contracts between teams, and those are interfaces and well-defined layers.

Multiple input and output ports

If your service receives requests via HTTP, gRPC and message queues, and persists to PostgreSQL, Redis and S3, the ports and adapters abstraction stops being theoretical. You have real adapters for each port.

// The same service serves HTTP, gRPC traffic and consumes from Kafka
func main() {
    svc := order.NewService(store, paymentGW, notifier)

    httpHandler := httpport.NewOrderHandler(svc)
    grpcHandler := grpcport.NewOrderServer(svc)
    consumer := kafkaport.NewOrderConsumer(svc)

    // ...
}

Strict testability requirements

If your CI pipeline demands high coverage with fast tests (without Docker containers for the database), you need interfaces to mock external dependencies. That’s legitimate.


My rules for architecture in Go

After several projects and quite a few mistakes, these are the rules I’ve arrived at. I don’t claim they’re universal, but they work for me:

1. Start flat, refactor when it hurts

The first design doesn’t need to be the definitive one. And I think that’s hard to accept, especially if you come from environments where refactoring is expensive. Go compiles so fast and refactors are so safe (thanks to the type system and gorename/gopls) that you can start with the simplest possible structure and add layers when complexity justifies it.

2. One package per domain, not per technical layer

Organize by what your code does, not by its technical role:

// ❌ By technical layer (Java-style)
internal/handlers/
internal/services/
internal/repositories/

// ✅ By domain (Go-style)
internal/user/
internal/order/
internal/payment/

This keeps together what changes together. If you need to modify the orders feature, everything is in internal/order/.

3. Interfaces only when there’s real polymorphism or you need tests

Don’t create an interface “just in case”. Create an interface when:

  • You have two or more real implementations.
  • You need a mock for a unit test.
  • A package needs to use something from another package without depending on its concrete implementation.

4. Define interfaces in the consumer

Always. No exceptions. If the order package needs something from the user package, the interface is defined in order. If that feels strange, it’s because you come from languages with explicit interfaces. In Go, this is the way.

5. main.go is your DI container

All explicit wiring in main.go. If it grows too much, extract setup* functions. Only consider DI libraries when you have a genuinely complex dependency graph (more than 30-40 components).

6. You don’t need separate DTOs if your structs already have JSON tags

In Java you need DTOs because your JPA entities have Hibernate annotations you don’t want to expose. In Go, a struct with json tags and db tags can serve both HTTP and persistence. Only separate them when the representation is genuinely different.

// This is valid and pragmatic
type User struct {
    ID        string    `json:"id" db:"id"`
    Name      string    `json:"name" db:"name"`
    Email     string    `json:"email" db:"email"`
    Password  string    `json:"-" db:"password_hash"`  // Not exposed in JSON
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

The json:"-" tag hides the field in HTTP responses. You don’t need a separate UserDTO for that.

7. Measure complexity by the cost of change

The interesting question is not “does this follow the clean architecture pattern?”. It’s another: “If I need to change the database from PostgreSQL to MongoDB, how many files do I touch?”. If the answer is “a few files in the store”, your architecture is fine. You don’t need an additional abstraction layer to make that change easier if the change is already manageable.

8. Review the architecture every 6 months

What worked with 3 developers and 10 endpoints may not work with 12 developers and 80 endpoints. Schedule periodic reviews of the project structure. Add layers when the pain is real, not when you anticipate it.


The empty chair test

A heuristic I use: if a new junior developer joins the team, can they find where the logic for “creating an order” lives in less than 30 seconds? If they have to navigate through domain/entity, application/usecase, infrastructure/persistence and adapter/http before understanding the flow, the architecture is not helping you. It’s getting in the way.

In the flat structure, look for internal/order/ and everything is there. Handler, service, store, types. One package, one domain, everything together.

This doesn’t mean every project should be flat. It means the complexity of the structure should be proportional to the complexity of the domain. A CRUD with five entities doesn’t need the same architecture as a real-time trading system.

If you want to go deeper into how to test an architecture like this, I recommend the article on testing in Go. If you need to set up a REST API with Go, there’s the practical guide.


Conclusion: architecture should serve the code

Clean architecture is not bad. Hexagonal isn’t either. They are tools, and as such, the question is not whether they are good or bad in the abstract. It’s whether the context justifies them.

Go was designed with a clear philosophy: simplicity, readability, composition over inheritance, the minimum necessary to get the job done. When you import Java or C# patterns without adapting them, you’re fighting the language.

My approach is to start simple. Packages by domain, three levels of responsibility (handler, service, store), interfaces only when there’s a real need. As the project grows and complexity appears, I add structure. Not before.

The best Go projects I’ve seen are not the ones with the most sophisticated architecture. They’re the ones with the architecture that’s just right for their level of complexity. No more, no less.

And in the end, I believe architecture is a means, not an end. If your structure helps you deliver faster, have fewer bugs and lets the team understand the code, it’s good. If it slows you down, forces you to touch five files for a trivial change and confuses newcomers, it’s bad. It doesn’t matter what you call it.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved