Arquitectura neta en Go: fins on té sentit aplicar-la
Clean architecture i hexagonal en Go: capes, interfícies, trade-offs i quan l'arquitectura ajuda vs quan mata la simplicitat de Go.

El meu primer projecte en Go semblava una aplicació Spring Boot sense Spring. Quatre capes, interfícies per tot arreu, un contenidor d’injecció de dependències i carpetes aniuades que necessitaven cinc nivells de profunditat per arribar al handler. Compilava ràpid, sí. Però navegar el codi era un malson. Cada canvi mínim requeria tocar tres fitxers i dues interfícies. Havia portat els meus hàbits de Java/Spring a un llenguatge que va ser dissenyat amb una filosofia radicalment diferent.
El problema no era la clean architecture en si. Era que l’aplicava sense qüestionar si cada capa, cada abstracció i cada interfície tenia sentit en el context de Go. I no el tenien.
Des de llavors he reescrit projectes, he treballat amb equips que cometen el mateix error i he arribat a un punt on crec que tinc bastant clar quan l’arquitectura ajuda i quan mata exactament el que fa atractiu Go. Encara que, sent honestos, segueixo descobrint matisos.
Què és la clean architecture (sense la definició de llibre)
Robert C. Martin la va formalitzar. El concepte és simple: separar el programari en capes concèntriques on les dependències apunten sempre cap endins, cap a les regles de negoci. La capa exterior (frameworks, bases de dades, HTTP) depèn de l’interior (casos d’ús, entitats), mai al revés.
L’arquitectura hexagonal (ports and adapters) d’Alistair Cockburn arriba al mateix per un altre camí: la teva lògica de negoci defineix ports (interfícies) i el món exterior proporciona adaptadors que els implementen. Base de dades, API HTTP, cua de missatges… són adaptadors intercanviables.
A la pràctica, totes dues convergeixen en el mateix:
- Entitats / domini: les regles de negoci pures.
- Casos d’ús / application: l’orquestració d’aquestes regles.
- Adaptadors / infrastructure: la implementació concreta de persistència, HTTP, etc.
- Frameworks / drivers: el pegament amb el món exterior.
A Java o C# això té sentit perquè els llenguatges t’empenten cap allà. Tens un framework pesat (Spring, ASP.NET) que necessites aïllar. Tens un contenidor d’injecció de dependències que resol grafs complexos d’objectes. Tens herència i polimorfisme explícit que fan que les interfícies siguin barates de mantenir.
Go no funciona així. I aquest és el punt de fricció.
Per què la gent porta clean architecture a Go
La resposta curta: perquè venim de Java, C# o algun llenguatge amb frameworks pesats. I no és que estigui malament tenir aquesta experiència, al contrari. El problema és un altre.
Quan portes anys treballant amb Spring Boot, el teu cervell té un motlle. Controlador, servei, repositori, DTO, mapper, interfície per a cada dependència, contenidor de DI. Ho fas en pilota automàtica. Quan comences amb Go, el primer impuls és replicar aquest motlle. I Go t’ho permet, perquè és prou flexible. Però que puguis fer-ho no significa que hagis de fer-ho.
He vist projectes Go amb aquesta estructura:
project/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── entity/
│ │ │ └── user.go
│ │ ├── repository/
│ │ │ └── user_repository.go // interfície
│ │ └── service/
│ │ └── user_service.go // interfície
│ ├── application/
│ │ └── usecase/
│ │ └── create_user.go
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ └── postgres_user_repo.go
│ │ ├── http/
│ │ │ └── user_handler.go
│ │ └── di/
│ │ └── container.go
│ └── adapter/
│ └── dto/
│ └── user_dto.go
└── pkg/Si vens de Spring Boot, això et resulta familiar. Si portes temps amb Go, probablement et provoca una reacció visceral. No perquè la separació sigui dolenta en si mateixa, sinó perquè la granularitat és excessiva per al que Go necessita.
Un handler que crida a un cas d’ús que crida a un servei que crida a un repositori… per guardar un usuari a PostgreSQL. Quatre nivells d’indireccions. A Go, on la navegació per codi és una de les millors experiències que existeixen gràcies a gopls, acabes saltant entre fitxers sense guanyar res a canvi.
El que Go ja et dóna: paquets com a fronteres
Però abans de ficar capes, crec que val la pena aturar-se un moment per entendre quins mecanismes d’organització et dóna Go de sèrie. Perquè de vegades la resposta ja és allà.
Paquets com a límits de mòdul
A Go, un paquet és un límit real. El que comença amb majúscula és públic; el que no, és privat al paquet. No necessites interfícies per ocultar implementació. El sistema de visibilitat del paquet ja ho fa.
// internal/user/store.go
package user
// store és privat al paquet. Ningú fora pot usar-lo directament.
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
}El paquet user exposa el que decideix exposar. No necessites una interfície UserRepository en un altre paquet per aconseguir encapsulació. El paquet és la frontera.
Interfícies implícites
I aquí és la diferència fonamental amb Java o C#. A Go, una interfície es satisfà implícitament. No declares implements. Si el teu struct té els mètodes, compleix la interfície. Punt.
Això canvia, d’una manera que al principi no és òbvia, on i quan defineixes interfícies. A Java defineixes la interfície on viu la implementació (o en el mòdul de domini). A Go, la convenció idiomàtica és definir la interfície on es consumeix, no on s’implementa.
// internal/order/service.go
package order
// El servei de comandes necessita buscar usuaris.
// Defineix la interfície aquí, on la consumeix.
type UserFinder interface {
FindByID(ctx context.Context, id string) (User, error)
}
type Service struct {
users UserFinder
}El paquet user ni tan sols sap que aquesta interfície existeix. Simplement té un mètode FindByID en alguna de les seves structs, i n’hi ha prou. Aquesta és la manera Go de fer les coses, i és molt més potent que el model explícit de Java perquè redueix l’acoblament a zero: el consumidor defineix el que necessita, el productor no ha de saber qui el consumeix.
Si vens de interfícies en Go, ja ho tens clar. Si no, val la pena aprofundir perquè canvia completament com dissenyes l’arquitectura.
Una arquitectura pragmàtica per a Go: handlers, services, repositories
Després de diversos projectes, he anat convergint en una estructura que, almenys en la meva experiència, funciona per a la majoria d’APIs i serveis. No té nom elegant. És simplement el mínim que manté el codi organitzat sense afegir capes innecessàries.
project/
├── cmd/
│ └── server/
│ └── main.go // composició, wiring, arrencada
├── internal/
│ ├── user/
│ │ ├── handler.go // HTTP handlers
│ │ ├── service.go // lògica de negoci
│ │ ├── store.go // accés a dades
│ │ └── user.go // tipus i models
│ ├── order/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── store.go
│ │ └── order.go
│ └── platform/
│ ├── database/
│ │ └── postgres.go // connexió a DB
│ └── server/
│ └── http.go // setup del servidor HTTP
└── go.modTres nivells de responsabilitat per domini. No quatre. No cinc. Tres:
Handler: rep la petició HTTP, valida l’input, crida al servei, retorna resposta. No coneix la base de dades.
Service: lògica de negoci. Orquestra operacions, aplica regles, coordina entre stores si cal. No coneix HTTP.
Store: accés a dades. SQL, Redis, crides a APIs externes. No coneix la lògica de negoci.
// 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
}Fixa’t: no hi ha interfícies encara. El Service depèn directament del Store concret. El Handler depèn directament del Service concret. I això està bé per a la majoria de projectes.
Per a més detalls sobre com organitzar això a nivell de carpetes, tinc un article dedicat a l’estructura de projecte Go.
Quan afegir capes: el llindar de complexitat
Doncs, si l’estructura simple funciona, la pregunta natural és: quan té sentit afegir capes i abstraccions? La regla que he anat destil·lant és bastant directa: quan el dolor és real, no anticipat.
Senyals que necessites més estructura
Necessites mockejar dependències externes en tests. Si el teu service crida directament a un store que depèn de PostgreSQL, i vols tests unitaris ràpids sense base de dades, necessites una interfície.
// internal/order/service.go
package order
import "context"
// Ara sí, definim interfícies. Perquè les necessitem per a 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
}Tens múltiples implementacions reals. Si el teu servei de notificacions pot enviar per email, SMS o push, aquesta abstracció té sentit real, no teòric.
Equips diferents treballen en capes diferents. Si un equip manté la lògica de negoci i un altre la integració amb infraestructura, la separació formal ajuda a definir contractes.
El domini és complex. Si tens regles de negoci amb invariants, estats, transicions i validacions complexes, aïllar el domini de la infraestructura val la pena.
Senyals que estàs sobre-arquitecturitzant
- Tens interfícies amb una sola implementació i no les uses en tests.
- Necessites mappers entre DTOs, models de domini i entitats de persistència que són bàsicament el mateix struct.
- Canviar un camp a la base de dades requereix tocar més de tres fitxers.
- Els nous membres de l’equip triguen més d’un dia a entendre on va cada cosa.
Si t’identifiques més amb el segon grup, probablement val la pena simplificar. Si t’identifiques més amb el primer, afegeix estructura. La resposta no ha de ser la mateixa per a tots els projectes.
Interfícies en arquitectura Go: defineix-les on es consumeixen
I aquí és on crec que veig més errors en projectes Go que intenten fer clean architecture. Gent que defineix interfícies en el paquet de domini, com faria a Java:
// ❌ Anti-patró: interfícies en el paquet de domini
// 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
}Això és Java amb sintaxi de Go. La interfície és enorme, té tots els mètodes possibles, i viu en un paquet que no la usa. Tot consumidor ha d’importar aquesta interfície encara que només necessiti un mètode.
La manera Go:
// ✅ Interfície en el consumidor, mínima
// internal/notification/service.go
package notification
type UserEmailFinder interface {
FindEmail(ctx context.Context, userID string) (string, error)
}
type Service struct {
users UserEmailFinder
// ...
}El servei de notificacions no necessita crear, actualitzar ni esborrar usuaris. Només necessita trobar un email. Defineix una interfície amb un sol mètode. Aquest és el principi de segregació d’interfícies portat a la seva màxima expressió, i a Go surt de manera natural gràcies a les interfícies implícites.
Interfícies petites, composades si cal
Si un servei necessita més mètodes, pots composar:
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
}
// Només si algú necessita totes dues
type UserStore interface {
UserReader
UserWriter
}La biblioteca estàndard de Go està plena d’exemples: io.Reader, io.Writer, io.ReadWriter. Interfícies d’un o dos mètodes que es composen. Aquest és el model a seguir.
Injecció de dependències sense contenidor
A Spring tens @Autowired o injecció per constructor amb un contenidor que resol tot el graf. A Go no necessites res d’això. El teu main.go és el teu contenidor de DI.
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"myapp/internal/order"
"myapp/internal/platform/database"
"myapp/internal/user"
)
func main() {
// Infraestructura
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))
}Tot el wiring és explícit. Pots llegir main.go i veure exactament de què depèn cada cosa. Sense màgia. Sense reflexió. Sense un contenidor que resol dependències en runtime.
Si el graf de dependències es torna complex, el màxim que faig és extreure funcions de setup:
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)
}Hi ha llibreries com wire de Google o fx d’Uber per a injecció de dependències en Go. Les he provat. Per a la majoria de projectes, el main.go explícit és millor. Quan main.go es torna inmanejable (més de 200 línies de wiring), pot tenir sentit introduir una d’aquestes eines. Però aquest llindar arriba molt més tard del que la gent creu.
La trampa del over-engineering: massa abstraccions maten la simplicitat de Go
Crec que el problema més gran que veig en projectes Go no és la manca d’arquitectura. És l’excés. I ho dic havent caigut en aquesta trampa jo mateix.
El cost real de cada abstracció
Cada interfície que afegeixes és una indireccions que algú ha de seguir en llegir el codi. Cada capa és un salt entre fitxers. Cada mapper és un lloc on pots tenir bugs de conversió. Cada paquet addicional és un import més que gestionar.
A Java, l’IDE t’oculta gran part d’aquest cost. IntelliJ genera implementacions, navega a través d’interfícies, autocompleta mocks. El cost de les abstraccions està parcialment subsidiat per les eines.
A Go, encara que gopls és excel·lent, la filosofia del llenguatge és que el codi sigui llegible directament. go doc mostra el que hi ha. grep funciona per trobar usos. Afegir capes d’abstracció innecessàries trenca aquesta experiència.
Un exemple del que NO cal fer
He vist això en un projecte real (noms canviats):
// 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
}Set fitxers. Quatre paquets. Un mapper que copia camps d’un struct a un altre gairebé idèntic. Un use case que és un wrapper d’una línia sobre un repositori. I al final, l’únic que fa és guardar una tasca a PostgreSQL. Tècnicament no estava equivocada la persona que el va escriure. Però el cost de navegació era desproporcionat per al que feia.
El mateix, en versió pragmàtica
// 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)
}Tres fitxers. Un paquet. Sense interfícies, sense mappers, sense use cases buits. Fa exactament el mateix. Si demà necessito tests unitaris del handler sense base de dades, llavors extraigo una interfície de l’store. No abans.
És menys “correcta” arquitecturalment? Depèn de qui preguntes. Jo crec que és més correcta per a Go, perquè respecta la filosofia del llenguatge: claredat, simplicitat, el mínim necessari. Però entenc que algú amb un background diferent pugui veure-ho d’una altra manera.
Quan la clean architecture completa sí té sentit
No vull que aquest article es llegeixi com a “mai facis clean architecture en Go”. Seria massa simplista. Hi ha contextos on té sentit ple:
Dominis complexos amb regles de negoci riques
Si estàs construint un sistema de facturació amb regles fiscals de múltiples països, estats de factures amb transicions controlades, validacions de negoci complexes i càlculs financers que no poden tenir bugs… aïllar el domini del framework i la base de dades és una inversió que es paga.
// internal/domain/invoice/invoice.go
package invoice
import "errors"
type Invoice struct {
id string
lines []Line
status Status
taxRules TaxRuleSet
}
// Lògica de domini pura, sense dependències d'infraestructura
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
}Aquí la separació del domini et permet testejar tota la lògica fiscal sense base de dades ni HTTP. Això val el seu pes en or.
Equips grans amb fronteres clares
Amb 15-20 desenvolupadors treballant en el mateix servei, les convencions de paquets de Go no basten per organitzar el treball. Necessites contractes formals entre equips, i això són interfícies i capes ben definides.
Múltiples ports d’entrada i sortida
Si el teu servei rep peticions per HTTP, gRPC i cues de missatges, i persisteix a PostgreSQL, Redis i S3, l’abstracció de ports i adaptadors deixa de ser teòrica. Tens adaptadors reals per a cada port.
// El mateix servei serveix tràfic HTTP, gRPC i consumeix de Kafka
func main() {
svc := order.NewService(store, paymentGW, notifier)
httpHandler := httpport.NewOrderHandler(svc)
grpcHandler := grpcport.NewOrderServer(svc)
consumer := kafkaport.NewOrderConsumer(svc)
// ...
}Requisits de testabilitat estrictes
Si el teu pipeline de CI exigeix cobertura alta amb tests ràpids (sense contenidors Docker per a la base de dades), necessites interfícies per mockejar dependències externes. Això és legítim.
Les meves regles per a arquitectura en Go
Després de diversos projectes i bastants errors, aquestes són les regles a les quals he anat arribant. No pretenc que siguin universals, però em funcionen:
1. Comença pla, refactoritza quan faci mal
El primer disseny no ha de ser el definitiu. I crec que això costa acceptar-ho, especialment si vens d’entorns on refactoritzar és car. Go compila tan ràpid i els refactors són tan segurs (gràcies al sistema de tipus i gorename/gopls) que pots començar amb l’estructura més simple possible i afegir capes quan la complexitat ho justifiqui.
2. Un paquet per domini, no per capa tècnica
Organitza pel que fa el teu codi, no pel seu rol tècnic:
// ❌ Per capa tècnica (estil Java)
internal/handlers/
internal/services/
internal/repositories/
// ✅ Per domini (estil Go)
internal/user/
internal/order/
internal/payment/Això manté relacionat el que canvia conjuntament. Si necessites modificar la feature de comandes, tot és a internal/order/.
3. Interfícies només quan hi ha polimorfisme real o necessites tests
No crees una interfície “per si de cas”. Crea una interfície quan:
- Tens dues o més implementacions reals.
- Necessites un mock per a un test unitari.
- Un paquet necessita usar alguna cosa d’un altre paquet sense dependre de la seva implementació concreta.
4. Defineix interfícies en el consumidor
Sempre. Sense excepcions. Si el paquet order necessita alguna cosa del paquet user, la interfície es defineix a order. Si això et sembla estrany, és perquè vens de llenguatges amb interfícies explícites. A Go, aquest és el camí.
5. El main.go és el teu contenidor de DI
Tot el wiring explícit a main.go. Si creix massa, extreu funcions setup*. Considera llibreries de DI només quan tinguis un graf de dependències genuïnament complex (més de 30-40 components).
6. No necessites DTOs separats si els teus structs ja tenen tags JSON
A Java necessites DTOs perquè les teves entitats JPA tenen anotacions d’Hibernate que no vols exposar. A Go, un struct amb tags json i tags db pot servir tant per HTTP com per persistència. Separa’ls només quan la representació sigui genuïnament diferent.
// Això és vàlid i pragmàtic
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"` // No s'exposa en JSON
CreatedAt time.Time `json:"created_at" db:"created_at"`
}El tag json:"-" oculta el camp en les respostes HTTP. No necessites un UserDTO separat per a això.
7. Mesura la complexitat pel cost del canvi
La pregunta interessant no és “això segueix el patró clean architecture?”. És una altra: “Si necessito canviar la base de dades de PostgreSQL a MongoDB, quants fitxers toco?”. Si la resposta és “uns quants fitxers en l’store”, la teva arquitectura està bé. No necessites una capa d’abstracció addicional per fer aquest canvi més fàcil si el canvi ja és manejable.
8. Revisa l’arquitectura cada 6 mesos
El que funcionava amb 3 desenvolupadors i 10 endpoints pot no funcionar amb 12 desenvolupadors i 80 endpoints. Programa revisions periòdiques de l’estructura del projecte. Afegeix capes quan el dolor sigui real, no quan l’anticipis.
El test de la cadira buida
Una heurística que faig servir: si un nou desenvolupador junior s’incorpora a l’equip, pot trobar on viu la lògica de “crear una comanda” en menys de 30 segons? Si ha de navegar per domain/entity, application/usecase, infrastructure/persistence i adapter/http abans d’entendre el flux, l’arquitectura no t’està ajudant. T’està posant obstacles.
En l’estructura plana, busca internal/order/ i té tot allà. Handler, servei, store, tipus. Un paquet, un domini, tot junt.
Això no significa que tot projecte hagi de ser pla. Significa que la complexitat de l’estructura ha de ser proporcional a la complexitat del domini. Un CRUD amb cinc entitats no necessita la mateixa arquitectura que un sistema de trading en temps real.
Si vols aprofundir en com es testa una arquitectura així, et recomano l’article sobre testing en Go. Si necessites muntar una API REST amb Go, allà tens la guia pràctica.
Conclusió: l’arquitectura ha de servir el codi
La clean architecture no és dolenta. L’hexagonal tampoc. Són eines, i com a tals, la pregunta no és si són bones o dolentes en abstracte. És si el context les justifica.
Go va ser dissenyat amb una filosofia clara: simplicitat, llegibilitat, composició sobre herència, el mínim necessari per fer la feina. Quan importes els patrons de Java o C# sense adaptar-los, estàs lluitant contra el llenguatge.
El meu enfocament és començar simple. Paquets per domini, tres nivells de responsabilitat (handler, service, store), interfícies només quan hi ha necessitat real. A mesura que el projecte creix i la complexitat apareix, afegeixo estructura. No abans.
Els millors projectes Go que he vist no són els que tenen l’arquitectura més sofisticada. Són els que tenen l’arquitectura justa per al seu nivell de complexitat. Ni més, ni menys.
I al final, crec que l’arquitectura és un mitjà, no un fi. Si la teva estructura t’ajuda a lliurar més ràpid, a tenir menys bugs i que l’equip entengui el codi, és bona. Si et frena, t’obliga a tocar cinc fitxers per un canvi trivial i confon els nous, és dolenta. És igual com la anomenes.


