Com organitzar un projecte en Go sense sobre-dissenyar-lo

Estructura de projecte en Go: paquets, cmd, internal, handlers, serveis i repositoris. Quan l'arquitectura ajuda i quan només afegeix soroll.

Cover for Com organitzar un projecte en Go sense sobre-dissenyar-lo

El meu primer projecte en Go venia d’anys amb Java i Spring Boot. Vaig fer el que qualsevol desenvolupador Java faria: vaig crear 20 paquets, tres capes d’abstracció, interfícies per a tot i un directori pkg/ perquè ho vaig veure en un repositori amb 30.000 estrelles a GitHub. El projecte era un CRUD que llegia de PostgreSQL i retornava JSON. Tècnicament no estava equivocat, però tota aquella arquitectura no servia absolutament per a res. Era sobre-enginyeria pura, i vaig trigar a adonar-me’n.

Go té una filosofia molt clara sobre l’estructura de projectes, i aquesta filosofia es resumeix en una frase que repeteixen constantment a la comunitat: “A little copying is better than a little dependency.” La simplicitat no és un defecte, és una decisió de disseny. I l’estructura del teu projecte hauria de reflectir-ho.


La filosofia de Go: comença pla, organitza quan faci mal

A Java, comences un projecte i abans d’escriure una línia de lògica ja tens src/main/java/com/empresa/projecte/controller/, service/, repository/, model/, dto/, config/, exception/… És l’estàndard. Ningú ho qüestiona.

A Go, el punt de partida és radicalment diferent. Un projecte pot ser un únic fitxer main.go i funcionar perfectament. No hi ha convenció imposada pel framework perquè, en la majoria de casos, no hi ha framework. No hi ha un Maven que t’obligui a una estructura de directoris. No hi ha un Spring que esperi trobar les teves classes en paquets concrets.

Això desorienta al principi — a mi em va desorientar bastant, venint de Spring —, però té un avantatge enorme: l’estructura del teu projecte reflecteix la complexitat real del teu projecte, no la complexitat que un framework t’obliga a anticipar.

La regla general que funciona:

  1. Comença amb tot al paquet main.
  2. Quan un fitxer creix massa, extreu un paquet.
  3. Quan tens diversos binaris, usa cmd/.
  4. Quan necessites protegir paquets interns, usa internal/.
  5. Si res et fa mal, no reorganitzis.

Això no és mandra. És Go idiomàtic.


L’estructura mínima viable: main.go i poc més

Per a un projecte petit (una eina CLI, un microservei senzill, un script que es queda en producció més del que hauria), aquesta estructura és perfectament vàlida:

el-meu-projecte/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
└── repository.go

Tot viu al paquet main. Sense subdirectoris. Sense interfícies abstractes. Sense pkg/. I funciona.

// main.go
package main

import (
    \"log\"
    \"net/http\"
)

func main() {
    repo := NewPostgresRepo(\"postgres://localhost:5432/mydb\")
    svc := NewTaskService(repo)
    handler := NewTaskHandler(svc)

    mux := http.NewServeMux()
    mux.HandleFunc(\"GET /tasks\", handler.ListTasks)
    mux.HandleFunc(\"POST /tasks\", handler.CreateTask)
    mux.HandleFunc(\"GET /tasks/{id}\", handler.GetTask)

    log.Println(\"Server starting on :8080\")
    log.Fatal(http.ListenAndServe(\":8080\", mux))
}
// handler.go
package main

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

type TaskHandler struct {
    service *TaskService
}

func NewTaskHandler(s *TaskService) *TaskHandler {
    return &TaskHandler{service: s}
}

func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.service.GetAll(r.Context())
    if err != nil {
        http.Error(w, \"internal error\", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(tasks)
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
    var t Task
    if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
        http.Error(w, \"bad request\", http.StatusBadRequest)
        return
    }
    created, err := h.service.Create(r.Context(), t)
    if err != nil {
        http.Error(w, \"internal error\", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(created)
}

func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue(\"id\")
    task, err := h.service.GetByID(r.Context(), id)
    if err != nil {
        http.Error(w, \"not found\", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(task)
}

Això és un servei real. Llegeix de base de dades, exposa una API HTTP, té separació de responsabilitats. I no necessita més estructura que quatre fitxers a l’arrel.

El moment de reorganitzar arriba quan:

  • Tens més de 10-15 fitxers a l’arrel i costa trobar coses.
  • Necessites reutilitzar lògica en més d’un binari.
  • Un altre equip o mòdul necessita importar els teus tipus.

Si cap d’aquestes coses passa, queda’t pla. I sent honestos, no guanyaràs punts per tenir més carpetes.


El patró cmd/: quan tens diversos binaris

El directori cmd/ apareix quan el teu projecte genera més d’un executable. Un cas típic: tens una API i un worker que processa cues, o una API i una eina CLI d’administració.

el-meu-projecte/
├── cmd/
│   ├── api/
│   │   └── main.go
│   └── worker/
│       └── main.go
├── internal/
│   ├── task/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   └── shared/
│       └── db.go
├── go.mod
└── go.sum

Cada subdirectori dins de cmd/ té el seu propi main.go amb package main. Cadascun compila a un binari independent.

// cmd/api/main.go
package main

import (
    \"log\"
    \"net/http\"

    \"el-meu-projecte/internal/task\"
    \"el-meu-projecte/internal/shared\"
)

func main() {
    db := shared.NewDB(\"postgres://localhost:5432/mydb\")
    repo := task.NewPostgresRepo(db)
    svc := task.NewService(repo)
    handler := task.NewHandler(svc)

    mux := http.NewServeMux()
    mux.HandleFunc(\"GET /tasks\", handler.List)
    mux.HandleFunc(\"POST /tasks\", handler.Create)

    log.Fatal(http.ListenAndServe(\":8080\", mux))
}
// cmd/worker/main.go
package main

import (
    \"log\"

    \"el-meu-projecte/internal/task\"
    \"el-meu-projecte/internal/shared\"
)

func main() {
    db := shared.NewDB(\"postgres://localhost:5432/mydb\")
    repo := task.NewPostgresRepo(db)
    svc := task.NewService(repo)

    log.Println(\"Worker starting...\")
    svc.ProcessPendingTasks()
}

La clau és que cmd/ només conté el punt d’entrada. La lògica real és a internal/. Els main.go de cmd/ haurien de ser fitxers curts que inicialitzen dependències i arrenquen el programa. Si el teu main.go té més de 50-60 línies, probablement estàs barrejant configuració amb lògica de negoci.

No usis cmd/ si només tens un binari. Un main.go a l’arrel és més simple i compleix la mateixa funció. De vegades la solució més avorrida és la correcta.


internal/: el control d’accés que Go porta de sèrie

internal/ és probablement la convenció més útil de Go i la menys entesa per qui ve d’altres llenguatges. No és una convenció social com src/ a Java o lib/ a Ruby. És una restricció del compilador. El propi go build impedeix que codi fora del teu mòdul importi paquets dins de internal/.

el-meu-projecte/
├── internal/
│   └── billing/
│       └── calculator.go    // Només accessible des de el-meu-projecte
├── pkg/
│   └── currency/
│       └── formatter.go     // Accessible per qualsevol
└── main.go

Si un altre projecte intenta fer import \"el-meu-projecte/internal/billing\", el compilador ho rebutja. No cal documentar que és privat, no cal confiar que la gent llegeixi el README. El compilador ho imposa.

Això et dóna llibertat per canviar l’API interna sense preocupar-te per trencar consumidors externs. I això, a la pràctica, significa que pots refactoritzar sense por. Crec que és una d’aquelles coses que no valores fins que la necessites de debò.

Quan usar internal/:

  • Quan el teu mòdul exposa alguna cosa pública (un CLI, una llibreria) i vols protegir la implementació.
  • Quan treballes en un monorepo i vols que certs paquets només els faci servir el teu servei.
  • Per defecte. Si no tens una raó perquè alguna cosa sigui pública, posa-la a internal/.

Quan NO necessites internal/:

  • Si el teu projecte és un binari que ningú importa. Tècnicament no importa on posis els paquets perquè ningú extern els farà servir. Però fins i tot així, internal/ comunica intenció i és un bon hàbit.

Una estructura pràctica per a backends: handlers, serveis, repositoris

Aquí és on cal prendre decisions reals. Quan el projecte creix prou per necessitar paquets separats, la pregunta és: organitzo per capa tècnica o per domini?

Per capa tècnica (evitar en la majoria de casos)

internal/
├── handlers/
│   ├── task_handler.go
│   ├── user_handler.go
│   └── project_handler.go
├── services/
│   ├── task_service.go
│   ├── user_service.go
│   └── project_service.go
├── repositories/
│   ├── task_repo.go
│   ├── user_repo.go
│   └── project_repo.go
└── models/
    ├── task.go
    ├── user.go
    └── project.go

Això és la traducció directa de l’estructura de Spring Boot. I funciona, no dic que no. Però té un problema fonamental a Go: genera dependències circulars amb facilitat. El paquet services necessita tipus de models i funcions de repositories. El paquet handlers necessita tipus de models i funcions de services. Qualsevol refactorització que creui capes es converteix en un maldecap.

Per domini (generalment millor)

internal/
├── task/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
├── user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
└── platform/
    ├── db.go
    ├── config.go
    └── middleware.go

Cada domini té tot el que necessita. task/ no necessita importar res de user/ en la majoria de casos. Si necessiten comunicar-se, defineixes una interfície al paquet que consumeix i deixes que l’altre la implementi.

// internal/task/service.go
package task

import \"context\"

type UserLookup interface {
    GetByID(ctx context.Context, id string) (User, error)
}

type Service struct {
    repo       Repository
    userLookup UserLookup
}

func NewService(repo Repository, ul UserLookup) *Service {
    return &Service{repo: repo, userLookup: ul}
}

La interfície UserLookup està definida al paquet task, no a user. Això és Go idiomàtic: les interfícies es defineixen on es consumeixen, no on s’implementen. És el contrari de Java, on defineixes la interfície al paquet del proveïdor.

Aquesta inversió subtil és el que fa que l’organització per domini funcioni sense dependències circulars. Reconec que em va costar interioritzar-ho venint del món JVM, però un cop ho entens, tot encaixa.

Si vols aprofundir en com aplicar aquests principis de manera més formal, ho cobreixo en detall a l’article sobre arquitectura neta en Go.


El debat de pkg/: per què molts desenvolupadors de Go l’eviten

Si busques “golang project structure” a Google, el primer que trobes és el repositori golang-standards/project-layout. Té més de 50.000 estrelles i proposa una estructura amb cmd/, internal/, pkg/, api/, web/, configs/, scripts/, build/, i una dotzena de directoris més.

El directori pkg/ se suposa que conté codi que pot ser importat per projectes externs. La idea és que si alguna cosa és a pkg/, és “pública” i estable.

El problema: pkg/ no té cap significat especial per al compilador. A diferència de internal/, que el compilador imposa, pkg/ és només un nom de directori. No afegeix protecció. No afegeix funcionalitat. Només afegeix un nivell d’anidament extra als teus imports.

// Amb pkg/
import \"el-meu-projecte/pkg/currency\"

// Sense pkg/
import \"el-meu-projecte/currency\"

La segona opció és més curta i no perd informació. Si currency/ és a l’arrel del mòdul i fora de internal/, ja és pública per defecte.

Russ Cox, un dels líders de l’equip de Go, ha dit explícitament que pkg/ com a convenció és innecessària per a la majoria de projectes. La recomanació oficial és simple: usa internal/ per al privat, posa el públic a l’arrel del mòdul o en subdirectoris amb noms descriptius.

Quan té sentit pkg/:

  • Si el teu projecte té molts directoris a l’arrel (configs, scripts, docs, deployments) i vols agrupar el codi Go públic en un sol lloc per no perdre’l entre el soroll.
  • Si ja el fas servir i canviar-lo trencaria imports d’altres projectes.

Quan no té sentit:

  • En la majoria de projectes. Si el teu projecte és un binari (una API, un CLI), ningú importa el teu codi. internal/ és tot el que necessites.
  • Si ets un equip petit i el “públic” del teu codi són els teus propis serveis. internal/ i l’arrel del mòdul cobreixen aquest cas.

No copiïs golang-standards/project-layout sense pensar

Aquest punt mereix la seva pròpia secció perquè és una trampa en la qual cauen molts desenvolupadors — jo inclòs al principi —, especialment els que venen d’ecosistemes amb estructures més rígides.

El repositori golang-standards/project-layout no és un estàndard oficial de Go. El nom és enganyós. El propi equip de Go ha dit públicament que no el recolza. Russ Cox va obrir un issue demanant que es canviés el nom perquè genera confusió.

El repositori mostra una estructura completa per a un projecte gran i madur. Aplicar-la a un microservei nou és com dissenyar l’arquitectura d’un centre comercial per obrir un lloc d’entrepans. Tècnicament tot és al seu lloc, però el cost de navegació i manteniment no es justifica.

El que sol passar:

  1. Desenvolupador nou en Go busca “com estructurar un projecte Go”.
  2. Troba golang-standards/project-layout.
  3. Copia tota l’estructura.
  4. Té un projecte amb cmd/, internal/, pkg/, api/, web/, configs/, deployments/, test/, tools/, examples/, third_party/, build/, assets/, docs/
  5. El 80% d’aquests directoris és buit o té un fitxer.
  6. Cada vegada que crea un fitxer nou, ha de decidir en quin dels 15 directoris va.

Crec que l’estructura hauria de ser una conseqüència de la complexitat del projecte, no una anticipació. Deixa que el projecte et digui quan necessita més estructura. Però la situació està canviant a poc a poc: cada vegada més gent a la comunitat de Go entén que aquella plantilla no és un estàndard, i això ajuda.


Quan l’estructura sí importa: projectes mitjans i grans

Tot l’anterior no significa que l’estructura sigui igual. Hi ha un punt d’inflexió on la manca d’organització comença a fer mal. Els símptomes són clars:

  • Fitxers de més de 500 línies on costa trobar funcions.
  • Dependències circulars entre paquets que no haurien de conèixer-se.
  • Noms genèrics com utils/, helpers/, common/ que es converteixen en calaixos de sastre.
  • Tests que importen mig projecte perquè tot està acoblat.
  • Nous membres de l’equip que tarden dies a entendre on va cada cosa.

Quan veus aquests símptomes, és el moment de reorganitzar. I el principi és sempre el mateix: agrupa per allò que canvia junt, no per allò que s’assembla tècnicament.

Si cada vegada que toques una funcionalitat de “facturació” has de modificar fitxers a handlers/, services/, repositories/ i models/, aquells quatre fitxers haurien d’estar en un paquet billing/. Si canviar el handler mai requereix canviar el repositori d’un altre domini, estan ben separats.

Per gestionar bé aquelles dependències entre paquets en projectes grans, tenir clar com funcionen els mòduls en Go és fonamental.


Exemples d’arbres: del projecte simple al multi-servei

Projecte simple: eina CLI o microservei petit

task-api/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
├── repository.go
├── model.go
└── README.md

Tot en package main. Sense subdirectoris. Perfecte per a un servei amb un domini, pocs endpoints i un sol binari.

Projecte API amb diversos dominis

order-service/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── order/
│   │   ├── handler.go
│   │   ├── handler_test.go
│   │   ├── service.go
│   │   ├── service_test.go
│   │   ├── repository.go
│   │   ├── repository_test.go
│   │   └── model.go
│   ├── product/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   └── model.go
│   ├── customer/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── model.go
│   └── platform/
│       ├── config/
│       │   └── config.go
│       ├── database/
│       │   └── postgres.go
│       ├── middleware/
│       │   ├── auth.go
│       │   ├── logging.go
│       │   └── recovery.go
│       └── server/
│           └── server.go
├── migrations/
│   ├── 001_create_orders.sql
│   └── 002_create_products.sql
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
└── README.md

Organització per domini dins de internal/. Els tests van al costat del codi que proven (això és convenció de Go, no en un directori test/ separat). El paquet platform/ agrupa infraestructura transversal: configuració, base de dades, middleware.

Per veure un exemple concret amb endpoints, middleware i PostgreSQL, mira l’article sobre com muntar una API REST amb Go.

Projecte multi-servei (monorepo)

ecommerce/
├── cmd/
│   ├── api/
│   │   └── main.go
│   ├── worker/
│   │   └── main.go
│   └── migrator/
│       └── main.go
├── internal/
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── events.go
│   │   └── model.go
│   ├── inventory/
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── consumer.go
│   │   └── model.go
│   ├── notification/
│   │   ├── service.go
│   │   ├── email.go
│   │   └── templates.go
│   └── platform/
│       ├── config/
│       │   └── config.go
│       ├── database/
│       │   └── postgres.go
│       ├── messaging/
│       │   └── kafka.go
│       ├── middleware/
│       │   ├── auth.go
│       │   └── logging.go
│       └── observability/
│           ├── metrics.go
│           └── tracing.go
├── migrations/
│   ├── 001_create_orders.sql
│   └── 002_create_inventory.sql
├── deployments/
│   ├── docker-compose.yml
│   └── k8s/
│       ├── api.yaml
│       └── worker.yaml
├── scripts/
│   └── seed.go
├── go.mod
├── go.sum
├── Makefile
└── README.md

Tres binaris a cmd/. Dominis clarament separats. Infraestructura compartida a platform/. Fitxers de desplegament a deployments/. Scripts auxiliars a scripts/.

Fixa’t que fins i tot en un projecte d’aquesta mida, l’estructura continua sent raonablement plana. Sense pkg/. Sense api/ amb definicions OpenAPI separades. Sense third_party/. Cada directori existeix perquè té un propòsit clar, no perquè una plantilla ho suggereixi.


Paquets: les regles no escrites

Més enllà de l’estructura de directoris, hi ha convencions sobre els propis paquets que t’estalviaran problemes:

Noms de paquets

  • Curts i descriptius: task, order, auth. No taskmanager, orderprocessing, authentication.
  • Sense prefixos redundants: El paquet task no necessita que els seus tipus es diguin TaskService o TaskHandler. A Go, els uses com task.Service i task.Handler. El nom del paquet ja dona context.
  • Mai utils, helpers, common, shared: Aquests noms no comuniquen res. Si tens funcions de formatació de moneda, crea un paquet currency. Si tens validadors, un paquet validation. El nom ha de dir què fa, no que és “útil”.
// Malament
utils.FormatCurrency(amount)

// Bé
currency.Format(amount)

Dependències circulars

Go no permet dependències circulars entre paquets. Si order importa customer i customer importa order, no compila. Punt.

Això sembla una limitació, i reconec que la primera vegada que m’hi vaig topar vaig pensar que ho era. Però a la pràctica, és un regal. T’obliga a pensar en la direcció de les dependències des del principi. Les solucions són:

  1. Interfícies al consumidor: Ja ho hem vist abans. El paquet que necessita alguna cosa defineix una interfície mínima i l’altre la implementa.
  2. Paquet intermedi: Si dos paquets necessiten tipus comuns, extreu aquells tipus a un tercer paquet que tots dos importin.
  3. Reorganitzar: De vegades la dependència circular indica que dos paquets haurien de ser un sol.

Un fitxer per responsabilitat

No facis fitxers gegants. Però tampoc creïs un fitxer per funció. La regla pràctica: un fitxer per concepte o tipus principal.

internal/order/
├── handler.go       // HTTP handlers per a orders
├── service.go       // Lògica de negoci
├── repository.go    // Accés a dades
├── model.go         // Tipus: Order, OrderItem, OrderStatus
├── events.go        // Esdeveniments de domini: OrderCreated, OrderShipped
└── validation.go    // Regles de validació específiques d'orders

Cada fitxer té entre 50 i 300 línies. Si passa de 400, probablement està fent massa.


Conclusió: deixa que el projecte et digui quan necessita més

Crec que l’estructura d’un projecte en Go hauria de ser la mínima necessària perquè l’equip (o tu sol) pugui treballar de manera productiva. No la mínima possible, ni la màxima que se t’acudeixi. La mínima necessària.

Comença pla. Extreu paquets quan notis fricció. Usa internal/ per defecte per a tot allò que no necessiti ser públic. Usa cmd/ quan tinguis més d’un binari. Organitza per domini, no per capa tècnica. Evita pkg/ tret que tinguis una raó concreta. I si us plau, no copiïs tot golang-standards/project-layout per a un microservei de quatre endpoints.

La millor estructura és la que el teu company nou entén en cinc minuts. Si necessita un diagrama de 20 caixes per saber on crear un fitxer, alguna cosa ha anat malament.

Go va ser dissenyat perquè el codi sigui avorrit de llegir. I sent honestos, que l’estructura del teu projecte també ho sigui és probablement el millor que li pot passar.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats