Structs en Go: cómo modelar datos sin clases tradicionales
Structs en Go explicados: métodos, composición, tags JSON, visibilidad y diferencias con la orientación a objetos clásica.

En Go no hay clases. No hay herencia. No hay constructores en el sentido tradicional. Y sin embargo, puedes modelar dominios complejos sin problema. No es que Go elimine el modelado de datos: elimina parte del teatro que a veces rodea la orientación a objetos.
Si vienes de Java o Kotlin, esto al principio incomoda. Estás acostumbrado a jerarquías de clases, a extends, a abstract, a patterns de herencia que llevas años practicando. Go te dice: no necesitas nada de eso. Lo que necesitas es un struct, unos métodos, y composición. Y resulta que con eso construyes software igual de expresivo y bastante más fácil de leer.
Los structs son el mecanismo fundamental de modelado en Go. Todo pasa por ellos: tus entidades de dominio, tus DTOs, tus configuraciones, tus respuestas de API. Entenderlos bien no es opcional. Es la base sobre la que se construye cualquier programa Go que vaya más allá de un script.
Definir un struct: campos, tipos y valores cero
Un struct en Go es una colección de campos con nombre y tipo. Nada más. No tiene métodos asociados en su definición (eso viene después). No tiene herencia. No tiene visibilidad por campo mediante keywords como private o protected. Es, deliberadamente, la estructura de datos más simple que puedes tener.
type User struct {
ID int
Name string
Email string
Active bool
CreatedAt time.Time
}Cada campo tiene un tipo explícito. No hay inferencia mágica, no hay tipos opcionales nativos (aunque puedes usar punteros para eso). Y algo que sorprende a muchos: todos los campos tienen un valor cero por defecto. No null, no nil (salvo en punteros, slices, maps y channels). Un valor cero concreto y determinista.
| Tipo | Valor cero |
|---|---|
int, float64 | 0 |
string | "" |
bool | false |
*T (puntero) | nil |
[]T (slice) | nil |
map[K]V | nil |
time.Time | Fecha cero (año 1) |
Esto significa que un User{} vacío es perfectamente válido: ID es 0, Name es "", Active es false, CreatedAt es la fecha cero. No lanza excepciones, no es null. Tiene un estado definido y predecible.
var u User
fmt.Println(u.Name) // "" (string vacío)
fmt.Println(u.Active) // false
fmt.Println(u.ID) // 0Si vienes de Java, piensa en esto: no necesitas inicializar campos. No necesitas constructores que asignen valores por defecto. El lenguaje ya te garantiza que cada tipo tiene un estado inicial seguro. Esto reduce una categoría entera de bugs relacionados con NullPointerException.
Crear instancias: sintaxis literal y funciones constructoras
Go no tiene una keyword new al estilo Java. Hay dos formas principales de crear structs, y cada una tiene su uso.
Sintaxis literal
La más directa. Creas el struct y asignas los campos que necesitas:
user := User{
ID: 1,
Name: "Roger",
Email: "roger@oshy.tech",
Active: true,
}Los campos que no especifiques toman su valor cero. Esto es intencional y útil: si un booleano por defecto debe ser false, simplemente no lo incluyes.
También puedes crear structs sin nombrar los campos, pero no lo hagas:
// Esto compila, pero es frágil e ilegible
user := User{1, "Roger", "roger@oshy.tech", true, time.Now()}Si alguien añade un campo al struct mañana, este código se rompe. Siempre usa la sintaxis con nombres de campo.
Funciones constructoras
Go no tiene constructores como método especial del tipo. En su lugar, la convención es crear una función que empiece con New:
func NewUser(name, email string) *User {
return &User{
Name: name,
Email: email,
Active: true,
CreatedAt: time.Now(),
}
}Fíjate en varios detalles:
- Devuelve un puntero (
*User), no un valor. Es la convención habitual cuando el struct va a ser modificado o compartido. - Establece valores por defecto (
Active: true,CreatedAt: time.Now()). - No necesita una keyword especial. Es una función normal. Sin magia.
Esta convención es tan fuerte en Go que cuando ves NewX sabes inmediatamente que es un constructor. Sin anotaciones, sin reflexión, sin un framework que registre nada.
El operador &
Puedes crear un puntero a un struct directamente con &:
user := &User{Name: "Roger"}Esto es equivalente a crear el struct y luego tomar su dirección. Es idiomático y lo verás en todas partes. Si no estás cómodo con punteros en Go, te recomiendo leer primero Punteros en Go antes de seguir.
Métodos: value receivers vs pointer receivers
Aquí es donde Go empieza a diferenciarse de las meras estructuras de datos. Los structs pueden tener métodos asociados, pero no se definen dentro del struct (como harías en una clase). Se definen fuera, con un receiver.
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}El (r Rectangle) antes del nombre del método es el receiver. Le dice a Go que este método pertenece al tipo Rectangle. Para usarlo:
rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area()) // 50
fmt.Println(rect.Perimeter()) // 30Hasta aquí todo parece cosmético. La diferencia real aparece con los pointer receivers.
Value receiver vs pointer receiver
Un value receiver trabaja sobre una copia del struct. Un pointer receiver trabaja sobre el original.
// Value receiver: NO modifica el original
func (r Rectangle) Scale(factor float64) Rectangle {
return Rectangle{
Width: r.Width * factor,
Height: r.Height * factor,
}
}
// Pointer receiver: SÍ modifica el original
func (r *Rectangle) ScaleInPlace(factor float64) {
r.Width *= factor
r.Height *= factor
}rect := Rectangle{Width: 10, Height: 5}
scaled := rect.Scale(2)
fmt.Println(rect.Width) // 10 (no ha cambiado)
fmt.Println(scaled.Width) // 20
rect.ScaleInPlace(2)
fmt.Println(rect.Width) // 20 (ha cambiado)Las reglas prácticas para elegir:
- Usa pointer receiver si el método modifica el struct.
- Usa pointer receiver si el struct es grande (evitas copiar mucha memoria).
- Usa value receiver si el struct es pequeño e inmutable (como una coordenada o un rango de tiempo).
- Sé consistente: si un método del tipo usa pointer receiver, que todos lo usen. Mezclar confunde y puede provocar bugs sutiles con interfaces.
En la práctica, la mayoría de métodos en código backend usan pointer receivers. Los structs suelen representar servicios, repositorios o entidades que se modifican o que son demasiado grandes para copiar en cada llamada.
Composición sobre herencia: structs embebidos
Go no tiene herencia. La palabra extends no existe. En su lugar, Go ofrece composición mediante structs embebidos (embedded structs). La diferencia no es solo sintáctica: cambia fundamentalmente cómo piensas sobre la relación entre tipos.
type Address struct {
Street string
City string
Country string
}
type Person struct {
Name string
Age int
Address // campo embebido (sin nombre explícito)
}El struct Person no “hereda de” Address. Contiene un Address. Pero al ser un campo embebido (sin nombre explícito), sus campos se promocionan: puedes acceder a ellos directamente.
p := Person{
Name: "Roger",
Age: 32,
Address: Address{
Street: "Carrer Major 1",
City: "Barcelona",
Country: "Spain",
},
}
fmt.Println(p.City) // "Barcelona" (promocionado)
fmt.Println(p.Address.City) // "Barcelona" (acceso explícito, también válido)Ambas formas funcionan. Y si Address tiene métodos, esos métodos también se promocionan:
func (a Address) FullAddress() string {
return fmt.Sprintf("%s, %s, %s", a.Street, a.City, a.Country)
}
fmt.Println(p.FullAddress()) // "Carrer Major 1, Barcelona, Spain"Por qué composición y no herencia
La herencia crea acoplamiento vertical. Si cambias la clase padre, afectas a todos los hijos. Si tienes herencia de varios niveles, rastrear de dónde viene un comportamiento se convierte en un ejercicio de arqueología.
La composición es horizontal. Un Person contiene un Address. Si mañana necesitas que también contenga un ContactInfo, lo añades. No necesitas reestructurar una jerarquía. No rompes contratos implícitos de herencia.
type ContactInfo struct {
Phone string
Email string
}
type Person struct {
Name string
Age int
Address
ContactInfo
}
p := Person{
Name: "Roger",
Age: 32,
Address: Address{City: "Barcelona"},
ContactInfo: ContactInfo{Email: "roger@oshy.tech"},
}
fmt.Println(p.Email) // "roger@oshy.tech"
fmt.Println(p.City) // "Barcelona"Si dos structs embebidos tienen un campo con el mismo nombre, Go no lo resuelve automáticamente. Te obliga a ser explícito. Esto es una decisión de diseño: la ambigüedad se resuelve en tiempo de compilación, no en runtime.
type A struct { Name string }
type B struct { Name string }
type C struct {
A
B
}
var c C
// c.Name <- error de compilación: ambiguo
c.A.Name = "desde A" // esto sí funcionaEsto elimina una categoría entera de bugs que en lenguajes con herencia múltiple (C++, Python) se resuelven con reglas de precedencia que nadie recuerda.
Tags JSON: marshaling y unmarshaling
Los tags de struct son una de las features más útiles y menos glamurosas de Go. Permiten añadir metadatos a los campos que las librerías pueden leer mediante reflexión. El caso más común: controlar cómo se serializa un struct a JSON.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
InStock bool `json:"in_stock"`
Description string `json:"description,omitempty"`
InternalSKU string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}Cada tag es un string entre backticks que sigue la convención key:"value". Para JSON:
json:"name"define el nombre del campo en JSON.json:"description,omitempty"omite el campo si tiene su valor cero (string vacío, 0, false, nil).json:"-"excluye el campo de la serialización. Útil para campos internos que no deben salir en la respuesta de una API REST.
Marshaling: struct a JSON
p := Product{
ID: 1,
Name: "Teclado mecánico",
Price: 89.99,
InStock: true,
InternalSKU: "KB-001-MX",
}
data, err := json.Marshal(p)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))Resultado:
{"id":1,"name":"Teclado mecánico","price":89.99,"in_stock":true,"created_at":"0001-01-01T00:00:00Z"}Fíjate: description no aparece porque usamos omitempty y estaba vacío. InternalSKU no aparece porque usamos "-". Los nombres de campo siguen la convención del tag, no la de Go.
Unmarshaling: JSON a struct
raw := `{"id":2,"name":"Ratón ergonómico","price":45.50,"in_stock":false}`
var p Product
if err := json.Unmarshal([]byte(raw), &p); err != nil {
log.Fatal(err)
}
fmt.Println(p.Name) // "Ratón ergonómico"
fmt.Println(p.Price) // 45.5El unmarshaling ignora campos del JSON que no tienen correspondencia en el struct, y deja en valor cero los campos del struct que no están en el JSON. Sin excepciones, sin errores. Esto es pragmatismo puro.
Tags más allá de JSON
Los tags no son exclusivos de JSON. Librerías como pgx (PostgreSQL), yaml, xml, validate y muchas otras los usan:
type Config struct {
Port int `yaml:"port" env:"APP_PORT" validate:"required,min=1024"`
Host string `yaml:"host" env:"APP_HOST" validate:"required"`
LogLevel string `yaml:"log_level" env:"LOG_LEVEL"`
}La clave es que los tags son solo metadatos. El compilador no los valida. Si escribes json:"naem" en vez de json:"name", compilará sin problema y tendrás un bug silencioso. Herramientas como go vet y linters ayudan a detectar estos errores.
Visibilidad: exportado vs no exportado
Go no tiene public, private ni protected. La visibilidad se controla con una regla brutal en su simplicidad: si empieza con mayúscula, es exportado (público). Si empieza con minúscula, no es exportado (privado al paquete).
type User struct {
ID int // exportado: visible fuera del paquete
Name string // exportado
email string // NO exportado: solo visible dentro del paquete
password string // NO exportado
}Esto aplica a todo: structs, campos, funciones, métodos, constantes, variables. Es una convención enforced por el compilador, no por un keyword. No puedes “saltártela” con reflexión de forma accidental.
Implicaciones prácticas
Cuando diseñas structs que forman parte de una API pública (un paquete que otros van a importar), la visibilidad de los campos importa mucho:
// paquete auth
type Credentials struct {
Username string // otros paquetes pueden leerlo y escribirlo
password string // solo el paquete auth puede acceder
}
func NewCredentials(username, password string) Credentials {
return Credentials{
Username: username,
password: hashPassword(password),
}
}
func (c Credentials) ValidatePassword(input string) bool {
return checkHash(c.password, input)
}Desde fuera del paquete:
creds := auth.NewCredentials("roger", "secreto123")
fmt.Println(creds.Username) // funciona
// fmt.Println(creds.password) // error de compilación
creds.ValidatePassword("secreto123") // funcionaEl campo password es inaccesible desde fuera. No necesitas getters/setters como en Java. El encapsulamiento es real y lo garantiza el compilador.
Visibilidad y JSON
Un detalle que pilla a muchos: los campos no exportados no se serializan a JSON. Si un campo empieza con minúscula, encoding/json lo ignora completamente:
type Response struct {
Status string `json:"status"`
message string `json:"message"` // NUNCA aparecerá en el JSON
}Esto es por diseño. Si necesitas que un campo aparezca en JSON, tiene que ser exportado. No hay forma de evitarlo con tags. Es una regla simple que evita exponer accidentalmente datos internos.
Comparación e igualdad de structs
Los structs en Go son comparables si todos sus campos son comparables. Puedes usar == directamente:
type Point struct {
X, Y int
}
a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}
fmt.Println(a == b) // true
fmt.Println(a == c) // falseEsto funciona porque int es comparable. Pero no todos los structs son comparables:
type Data struct {
Values []int // los slices NO son comparables
}
a := Data{Values: []int{1, 2}}
b := Data{Values: []int{1, 2}}
// fmt.Println(a == b) // error de compilaciónLos slices, maps y funciones no se pueden comparar con ==. Si tu struct contiene alguno de estos tipos, necesitas escribir tu propia función de comparación o usar reflect.DeepEqual (que es lento y debe reservarse para tests).
func (d Data) Equal(other Data) bool {
if len(d.Values) != len(other.Values) {
return false
}
for i, v := range d.Values {
if v != other.Values[i] {
return false
}
}
return true
}Regla práctica: si necesitas comparar structs complejos en código de producción, implementa un método
Equal. Si es solo para tests,reflect.DeepEqualo librerías comogo-cmpson aceptables.
Los structs comparables también pueden usarse como claves de maps, lo cual es útil para caches y lookups:
type Coordinate struct {
Lat, Lng float64
}
visited := map[Coordinate]bool{
{40.4168, -3.7038}: true, // Madrid
{41.3874, 2.1686}: true, // Barcelona
}Cuándo usar structs vs maps
Esta es una pregunta que surge mucho, sobre todo si vienes de Python o JavaScript, donde los diccionarios/objetos son la herramienta por defecto para todo.
En Go, la respuesta casi siempre es “usa un struct”. Pero hay excepciones.
Usa structs cuando:
- Conoces la forma de los datos en tiempo de compilación.
- Los campos tienen tipos diferentes.
- Necesitas métodos asociados.
- Quieres validación del compilador.
- Los datos se serializan/deserializan con una estructura conocida.
// Esto es un struct. Siempre.
type Order struct {
ID string
Customer string
Items []OrderItem
Total float64
Status string
CreatedAt time.Time
}Usa maps cuando:
- La forma de los datos es dinámica o desconocida en tiempo de compilación.
- Las claves son dinámicas (configuración, headers HTTP, metadatos).
- Estás trabajando con datos genéricos que no tienen una estructura fija.
// Metadatos dinámicos: esto es un map
metadata := map[string]string{
"source": "api",
"version": "2.1",
"region": "eu-west",
}
// Headers HTTP: también un map
headers := map[string][]string{
"Content-Type": {"application/json"},
"Authorization": {"Bearer token123"},
}El anti-pattern: map[string]interface
Si te encuentras escribiendo map[string]interface{} (o map[string]any desde Go 1.18) en todas partes, probablemente necesitas un struct. Los maps sin tipo pierden todas las garantías del compilador y convierten tu código Go en JavaScript con pasos extra.
// NO hagas esto para datos con estructura conocida
data := map[string]any{
"name": "Roger",
"age": 32,
"email": "roger@oshy.tech",
}
name := data["name"].(string) // type assertion, puede hacer panic en runtime
// Haz esto
user := User{
Name: "Roger",
Age: 32,
Email: "roger@oshy.tech",
}
// user.Name es string. Siempre. Sin discusión.Ejemplo práctico: modelar una respuesta de API
Vamos a juntar todo lo visto con un ejemplo real: modelar la respuesta de una API que devuelve una lista de artículos paginada. Este es el tipo de código que escribes constantemente en un backend Go.
package api
import (
"encoding/json"
"time"
)
// Article representa un artículo del blog.
type Article struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content,omitempty"`
Author Author `json:"author"`
Tags []string `json:"tags"`
PublishedAt time.Time `json:"published_at"`
Draft bool `json:"draft,omitempty"`
viewCount int // no exportado: no aparece en JSON, no accesible fuera del paquete
}
// Author usa composición en vez de duplicar campos.
type Author struct {
Name string `json:"name"`
Email string `json:"email"`
Bio string `json:"bio,omitempty"`
}
// Pagination encapsula los datos de paginación.
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
}
// ArticleListResponse es la respuesta completa de la API.
type ArticleListResponse struct {
Data []Article `json:"data"`
Pagination Pagination `json:"pagination"`
}
// NewPagination calcula los campos derivados automáticamente.
func NewPagination(page, perPage, total int) Pagination {
totalPages := total / perPage
if total%perPage != 0 {
totalPages++
}
return Pagination{
Page: page,
PerPage: perPage,
Total: total,
TotalPages: totalPages,
HasNext: page < totalPages,
}
}
// ToJSON serializa la respuesta. Método con value receiver
// porque ArticleListResponse no necesita ser modificado.
func (r ArticleListResponse) ToJSON() ([]byte, error) {
return json.MarshalIndent(r, "", " ")
}Usándolo:
response := api.ArticleListResponse{
Data: []api.Article{
{
ID: "1",
Title: "Structs en Go",
Slug: "structs-go",
Author: api.Author{
Name: "Roger Bosch",
Email: "roger@oshy.tech",
},
Tags: []string{"Go", "structs", "backend"},
PublishedAt: time.Now(),
},
},
Pagination: api.NewPagination(1, 10, 1),
}
data, err := response.ToJSON()
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))Resultado:
{
"data": [
{
"id": "1",
"title": "Structs en Go",
"slug": "structs-go",
"author": {
"name": "Roger Bosch",
"email": "roger@oshy.tech"
},
"tags": ["Go", "structs", "backend"],
"published_at": "2026-06-24T10:30:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 1,
"total_pages": 1,
"has_next": false
}
}En este ejemplo aparecen todos los conceptos: structs con campos tipados, composición (Author dentro de Article), tags JSON con omitempty, campos no exportados (viewCount), funciones constructoras (NewPagination), métodos con value receiver (ToJSON), y serialización limpia.
Patrones comunes en código backend real
Para cerrar, estos son patrones que verás (y escribirás) constantemente si haces backend en Go. No son teóricos: salen directamente de código en producción.
El patrón Options para constructores complejos
Cuando un constructor tiene demasiados parámetros, el patrón funcional options es idiomático en Go:
type Server struct {
host string
port int
readTimeout time.Duration
writeTimeout time.Duration
logger *slog.Logger
}
type Option func(*Server)
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeouts(read, write time.Duration) Option {
return func(s *Server) {
s.readTimeout = read
s.writeTimeout = write
}
}
func WithLogger(logger *slog.Logger) Option {
return func(s *Server) {
s.logger = logger
}
}
func NewServer(host string, opts ...Option) *Server {
s := &Server{
host: host,
port: 8080,
readTimeout: 5 * time.Second,
writeTimeout: 10 * time.Second,
logger: slog.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}srv := NewServer("localhost",
WithPort(9090),
WithTimeouts(10*time.Second, 30*time.Second),
)Este patrón te da valores por defecto sensatos, parámetros opcionales con nombre, y la posibilidad de extender la configuración sin romper la API. Lo usan librerías como google.golang.org/grpc y go.uber.org/zap.
Struct como receptor de servicio
En backend Go, los servicios y repositorios son structs con dependencias inyectadas por constructor. Es el equivalente a una clase con @Service en Spring, pero sin anotaciones ni reflexión:
type UserService struct {
repo UserRepository
cache Cache
logger *slog.Logger
}
func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
return &UserService{
repo: repo,
cache: cache,
logger: logger,
}
}
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
if cached, ok := s.cache.Get(ctx, "user:"+id); ok {
return cached.(*User), nil
}
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding user %s: %w", id, err)
}
s.cache.Set(ctx, "user:"+id, user, 5*time.Minute)
return user, nil
}Fíjate que UserRepository y Cache son interfaces, no structs concretos. Esto permite inyectar implementaciones diferentes en tests (mocks) y en producción. La regla de Go aplica: acepta interfaces, devuelve structs.
DTOs separados de entidades de dominio
No mezcles tu modelo de dominio con tus DTOs de API. Son responsabilidades distintas:
// Entidad de dominio (capa interna)
type Task struct {
ID string
Title string
Description string
Status TaskStatus
AssigneeID string
CreatedAt time.Time
UpdatedAt time.Time
}
// DTO de respuesta (capa de API)
type TaskResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Assignee string `json:"assignee,omitempty"`
CreatedAt string `json:"created_at"`
}
// Conversión explícita
func ToTaskResponse(t Task) TaskResponse {
return TaskResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
Status: string(t.Status),
Assignee: t.AssigneeID,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
}Es más código que poner tags JSON directamente en la entidad. Pero cuando tu API necesite devolver datos de forma diferente a como los almacenas (y esto pasa siempre), tendrás la separación hecha. Lo explico en más detalle en estructura de proyecto y arquitectura limpia en Go.
Structs como configuración tipada
En vez de leer variables de entorno como strings desperdigados por el código, centraliza la configuración en un struct:
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
}
type ServerConfig struct {
Host string `env:"SERVER_HOST" envDefault:"0.0.0.0"`
Port int `env:"SERVER_PORT" envDefault:"8080"`
}
type DatabaseConfig struct {
URL string `env:"DATABASE_URL,required"`
MaxConns int `env:"DB_MAX_CONNS" envDefault:"25"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}
type RedisConfig struct {
Addr string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
Password string `env:"REDIS_PASSWORD"`
DB int `env:"REDIS_DB" envDefault:"0"`
}Con una librería como github.com/caarlos0/env puedes llenar este struct directamente desde variables de entorno. Resultado: configuración tipada, con valores por defecto, con validación, y sin un solo os.Getenv suelto en tu código.
Menos ceremonia, más claridad
Los structs en Go son simples por diseño. No tienen herencia, no tienen constructores mágicos, no tienen visibilidad granular con cinco keywords. Y eso es precisamente lo que los hace poderosos: te obligan a modelar tus datos de forma explícita, a componer en vez de heredar, a ser claro en vez de abstracto.
Si vienes de lenguajes con orientación a objetos clásica, el cambio puede parecer un paso atrás. Pero después de usar Go en producción durante un tiempo, te das cuenta de que la mayoría de abstracciones OOP que usabas no eran necesarias. Eran ceremoniales. Go elimina la ceremonia y te deja con lo que importa: datos, comportamiento, y composición.
Los structs son la base. Sobre ellos construyes servicios, handlers de API, repositorios, configuración, DTOs. Para el siguiente paso, entiende cómo las interfaces en Go complementan a los structs, y cómo juntos forman el sistema de tipos que hace que Go sea Go. Y cuando estés listo para construir algo real, una API REST con Go es el mejor campo de pruebas.


