Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save hamzaahmedkhan/50d05374af7712e914da04830f3147dd to your computer and use it in GitHub Desktop.

Select an option

Save hamzaahmedkhan/50d05374af7712e914da04830f3147dd to your computer and use it in GitHub Desktop.
A practical reference for engineers moving from Spring Boot (Kotlin/Java) to Go (go-zero + GORM).

Spring Boot → Go Migration Guidebook

A practical reference for engineers moving from Spring Boot (Kotlin/Java) to Go (go-zero + GORM). Based on the company-crm-service codebase.


Table of Contents

  1. Philosophy: What Changes, What Doesn't
  2. Project Structure
  3. Request Lifecycle
  4. Entities & Models
  5. Repository → Model Layer
  6. Service → Logic Layer
  7. Controller → Handler Layer
  8. Route Registration
  9. Dependency Injection
  10. Configuration
  11. Error Handling
  12. Middleware / Filters
  13. HTTP Client (Feign → Go)
  14. Database Migrations
  15. Go Concepts That Have No Spring Equivalent
  16. Quick Reference Cheat Sheet

1. Philosophy

What Stays the Same

  • 3-layer architecture — Controller → Service → Repository maps to Handler → Logic → Model
  • ORM — GORM works like Hibernate/Spring Data JPA
  • YAML config — same idea, different structure
  • Migrations — Liquibase is used in both

What Changes Fundamentally

Spring Boot Go
Framework does the wiring (DI, annotations, scanning) You do the wiring — manually, explicitly
Annotations everywhere (@Service, @Autowired) No annotations — plain structs and interfaces
Exceptions for error flow Errors are return values — (result, error)
Implicit null via Optional<T> Explicit null via pointers *string, *int
Spring manages threads You manage goroutines
Maven/Gradle go.mod (much simpler)
JVM startup overhead Compiles to a single binary, starts in milliseconds

The Core Mental Shift

In Spring, the framework finds your code and runs it. In Go, your code is always explicit — you can trace every call by reading top to bottom.

There is no magic. No proxies. No AOP that silently wraps your methods. What you write is what runs.


2. Project Structure

Spring Boot (Kotlin)                      Go (go-zero + GORM)
─────────────────────────────────────     ──────────────────────────────────────
src/main/java/com/company/
├── controller/                           internal/handler/
│   └── EmployeeController.kt                 ├── employee/
│                                             │   ├── createemployeehandler.go
│                                             │   └── getemployeehandler.go
│                                             ├── zone/
│                                             └── routes.go          ← ALL routes here
│
├── service/                              internal/logic/
│   ├── EmployeeService.kt (interface)        ├── employee/
│   └── EmployeeServiceImpl.kt                   ├── createemployeelogic.go
│                                               └── getemployeelogic.go
│                                           └── zone/
│
├── repository/                           model/
│   └── EmployeeRepository.kt                 ├── employeemodel.go   ← interface + GORM impl
│                                             └── zonemodel.go
│
├── entity/                               model/                     ← same file as repository
│   └── Employee.kt (@Entity)
│
├── dto/                                  internal/types/
│   ├── CreateEmployeeRequest.kt              └── types.go
│   └── EmployeeResponse.kt
│
├── config/                               internal/config/
│   └── AppConfig.kt                          ├── config.go
│                                             └── database.go
│
├── exception/                            model/vars.go
│   └── GlobalExceptionHandler.kt            + httpx.ErrorCtx() in handlers
│
├── interceptor/ / @Aspect                internal/middleware/
│   └── AuthInterceptor.kt                    └── scopemiddleware.go
│
└── Application.kt                        sample.go                  ← main()

src/main/resources/
├── application.yml                       etc/company-crm-api-local.yaml
└── db/migration/ (Flyway)               database/migrations/ (Liquibase)

pom.xml / build.gradle                    go.mod

3. Request Lifecycle

Spring Boot

HTTP Request
    → DispatcherServlet
    → Filter chain (@Aspect, interceptors)
    → @RestController method
    → @Service method
    → @Repository method (Spring Data JPA)
    → Hibernate → DB

Go (go-zero)

HTTP Request
    → go-zero router (registered in routes.go)
    → Middleware chain (scopemiddleware.go)
    → HandlerFunc (e.g. createemployeehandler.go)
    → Logic (e.g. createemployeelogic.go)
    → Model method (e.g. employeemodel.go)
    → GORM → DB

They are structurally identical. The difference is Spring automates the wiring; Go makes you do it once in routes.go and servicecontext.go.


4. Entities & Models

Spring Boot — @Entity

@Entity
@Table(name = "employees")
data class Employee(
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    val id: UUID = UUID.randomUUID(),

    @Column(name = "user_id", nullable = false, unique = true)
    val userId: UUID,

    @Column(nullable = false)
    val designation: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "manager_id")
    val manager: Employee? = null,

    @OneToMany(mappedBy = "manager")
    val reports: List<Employee> = emptyList(),

    @ManyToMany
    @JoinTable(
        name = "employee_zone_assignments",
        joinColumns = [JoinColumn(name = "employee_id")],
        inverseJoinColumns = [JoinColumn(name = "zone_id")]
    )
    val zones: List<Zone> = emptyList(),

    @Column(name = "is_active")
    val isActive: Boolean = true,

    @CreatedDate val createdAt: LocalDateTime = LocalDateTime.now(),
    @LastModifiedDate val updatedAt: LocalDateTime = LocalDateTime.now()
)

Go — GORM Struct

// model/employeemodel.go
type Employee struct {
    ID          string     `gorm:"type:uuid;primaryKey"`
    UserID      string     `gorm:"type:uuid;uniqueIndex;not null"`
    Designation string     `gorm:"not null"`
    ManagerID   *string    `gorm:"type:uuid;index"`
    Manager     *Employee  `gorm:"foreignKey:ManagerID"`          // @ManyToOne
    Reports     []Employee `gorm:"foreignKey:ManagerID"`          // @OneToMany
    Zones       []Zone     `gorm:"many2many:employee_zone_assignments;..."` // @ManyToMany
    IsActive    bool       `gorm:"not null;default:true"`
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// UUID auto-generated before insert (replaces @GeneratedValue)
func (e *Employee) BeforeCreate(_ *gorm.DB) error {
    if e.ID == "" {
        e.ID = uuid.New().String()
    }
    return nil
}

Annotation → Tag Mapping

Spring / JPA GORM struct tag
@Id gorm:"primaryKey"
@GeneratedValue BeforeCreate hook
@Column(nullable = false) gorm:"not null"
@Column(unique = true) gorm:"uniqueIndex"
@Column(name = "user_id") gorm:"column:user_id" (or snake_case auto-mapping)
@ManyToOne gorm:"foreignKey:FieldName"
@OneToMany(mappedBy) gorm:"foreignKey:FieldName"
@ManyToMany + @JoinTable gorm:"many2many:table_name"
@CreatedDate CreatedAt time.Time (GORM auto-fills)
@LastModifiedDate UpdatedAt time.Time (GORM auto-fills)

Loading Associations

// Spring — lazy loading is automatic (can cause N+1)
val manager = employee.manager  // triggered lazily
// Go — GORM requires explicit Preload (no lazy loading)
db.Preload("Manager").First(&employee, "id = ?", id)
db.Preload("Zones").Preload("Manager").First(&employee, "id = ?", id)
// This is actually better — you always know what queries run

5. Repository → Model Layer

Spring Boot

@Repository
interface EmployeeRepository : JpaRepository<Employee, UUID> {
    // Spring Data auto-generates from method names:
    fun findByUserId(userId: UUID): Optional<Employee>
    fun findAllByDesignation(designation: String): List<Employee>
    fun findAllByManagerId(managerId: UUID): List<Employee>
}

Go — Interface + GORM Implementation

// model/employeemodel.go

// Step 1: Define the interface (like the Repository interface in Spring)
type EmployeeModel interface {
    Insert(ctx context.Context, emp *Employee) error
    FindByID(ctx context.Context, id string) (*Employee, error)
    FindByUserID(ctx context.Context, userID string) (*Employee, error)
    List(ctx context.Context, filter EmployeeFilter) ([]*Employee, error)
    Update(ctx context.Context, emp *Employee) error
}

// Step 2: Implement it (Spring Data auto-generates this; you write it in Go)
type gormEmployeeModel struct{ db *gorm.DB }

func NewEmployeeModel(db *gorm.DB) EmployeeModel {
    return &gormEmployeeModel{db: db}
}

func (m *gormEmployeeModel) FindByID(ctx context.Context, id string) (*Employee, error) {
    var emp Employee
    err := m.db.WithContext(ctx).First(&emp, "id = ?", id).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, ErrNotFound
    }
    return &emp, err
}

func (m *gormEmployeeModel) List(ctx context.Context, filter EmployeeFilter) ([]*Employee, error) {
    q := m.db.WithContext(ctx).Model(&Employee{})
    if filter.Designation != "" {
        q = q.Where("designation = ?", filter.Designation)
    }
    if filter.ManagerID != "" {
        q = q.Where("manager_id = ?", filter.ManagerID)
    }
    var employees []*Employee
    return employees, q.Order("created_at ASC").Find(&employees).Error
}

Key difference: Spring Data generates queries from method names magically. In Go you write the queries yourself — more code, nothing hidden, easier to debug.


6. Service → Logic Layer

Spring Boot

@Service
class CreateEmployeeService(
    private val employeeRepository: EmployeeRepository,  // injected automatically
    private val logger: Logger = LoggerFactory.getLogger(CreateEmployeeService::class.java)
) {
    fun createEmployee(request: CreateEmployeeRequest): EmployeeResponse {
        if (employeeRepository.existsByUserId(request.userId)) {
            throw ConflictException("Employee already exists")
        }
        val emp = Employee(userId = request.userId, designation = request.designation)
        return employeeRepository.save(emp).toResponse()
    }
}

Go — Logic

// internal/logic/employee/createemployeelogic.go
type CreateEmployeeLogic struct {
    logx.Logger              // embedded logger (like Slf4j — auto-available as l.Info(), l.Error())
    ctx    context.Context
    svcCtx *svc.ServiceContext  // holds all dependencies (replaces @Autowired fields)
}

func NewCreateEmployeeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateEmployeeLogic {
    return &CreateEmployeeLogic{
        Logger: logx.WithContext(ctx),
        ctx:    ctx,
        svcCtx: svcCtx,
    }
}

func (l *CreateEmployeeLogic) CreateEmployee(req *types.CreateEmployeeRequest) (*types.EmployeeResponse, error) {
    designation, err := domain.ParseDesignation(req.Designation)
    if err != nil {
        return nil, err  // no exceptions — return error as value
    }

    emp := &model.Employee{
        UserID:      req.UserID,
        Designation: designation.String(),
        ManagerID:   req.ManagerID,
        IsActive:    true,
    }

    if err := l.svcCtx.EmployeeModel.Insert(l.ctx, emp); err != nil {
        return nil, err
    }

    return toEmployeeResponse(emp), nil
}

One Logic File Per Use Case

Unlike Spring where one @Service class handles all employee operations, in go-zero each use case gets its own file:

internal/logic/employee/
├── createemployeelogic.go   ← CreateEmployeeService
├── getemployeelogic.go      ← GetEmployeeService / findById
├── listemployeeslogic.go    ← ListEmployeeService / findAll
├── updateemployeelogic.go   ← UpdateEmployeeService
├── assignzonelogic.go       ← AssignZoneService
└── helpers.go               ← shared toEmployeeResponse() converter

7. Controller → Handler Layer

Spring Boot

@RestController
@RequestMapping("/v1/employees")
class EmployeeController(
    private val createEmployeeService: CreateEmployeeService
) {
    @PostMapping
    @ResponseStatus(HttpStatus.OK)
    fun create(@RequestBody @Valid request: CreateEmployeeRequest): EmployeeResponse {
        return createEmployeeService.createEmployee(request)
    }

    @GetMapping("/{id}")
    fun getById(@PathVariable id: UUID): EmployeeResponse {
        return getEmployeeService.getEmployee(id)
    }
}

Go — Handler

// internal/handler/employee/createemployeehandler.go
func CreateEmployeeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req types.CreateEmployeeRequest
        if err := httpx.Parse(r, &req); err != nil {   // like @RequestBody + @Valid
            httpx.ErrorCtx(r.Context(), w, err)
            return
        }
        l := employeelogic.NewCreateEmployeeLogic(r.Context(), svcCtx)
        resp, err := l.CreateEmployee(&req)
        if err != nil {
            httpx.ErrorCtx(r.Context(), w, err)        // like @ExceptionHandler
            return
        }
        httpx.OkJsonCtx(r.Context(), w, resp)
    }
}

Request Parsing — Annotation vs Struct Tags

Spring annotation Go struct tag What it does
@PathVariable id ID string \path:"id"`` Read from URL path
@RequestParam name Name string \form:"name"`` Read from query string
@RequestBody json:"field_name" Read from JSON body
@RequestParam(required=false) form:"name,optional" Optional query param

All resolved by a single call: httpx.Parse(r, &req)


8. Route Registration

Spring Boot — Routes live ON the controller

@RestController
@RequestMapping("/v1/employees")     // ← route here
class EmployeeController {
    @PostMapping                     // ← route here
    fun create(...) {}

    @GetMapping("/{id}")             // ← route here
    fun getById(...) {}
}
// Spring finds controllers via @ComponentScan automatically

Go — ALL routes in ONE file

// internal/handler/routes.go ← single source of truth for all routes
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
    server.AddRoutes(
        rest.WithMiddlewares(
            []rest.Middleware{serverCtx.ScopeMiddleware},
            []rest.Route{
                // Zones
                {Method: http.MethodPost,  Path: "/v1/zones",     Handler: zonehandler.CreateZoneHandler(serverCtx)},
                {Method: http.MethodGet,   Path: "/v1/zones",     Handler: zonehandler.ListZonesHandler(serverCtx)},
                {Method: http.MethodGet,   Path: "/v1/zones/:id", Handler: zonehandler.GetZoneHandler(serverCtx)},
                {Method: http.MethodPatch, Path: "/v1/zones/:id", Handler: zonehandler.UpdateZoneHandler(serverCtx)},

                // Employees
                {Method: http.MethodPost,   Path: "/v1/employees",           Handler: employeehandler.CreateEmployeeHandler(serverCtx)},
                {Method: http.MethodGet,    Path: "/v1/employees",           Handler: employeehandler.ListEmployeesHandler(serverCtx)},
                {Method: http.MethodGet,    Path: "/v1/employees/:id",       Handler: employeehandler.GetEmployeeHandler(serverCtx)},
                {Method: http.MethodPatch,  Path: "/v1/employees/:id",       Handler: employeehandler.UpdateEmployeeHandler(serverCtx)},
                {Method: http.MethodPost,   Path: "/v1/employees/:id/zones", Handler: employeehandler.AssignZoneHandler(serverCtx)},
                {Method: http.MethodGet,    Path: "/v1/employees/:id/zones", Handler: employeehandler.ListEmployeeZonesHandler(serverCtx)},
                {Method: http.MethodDelete, Path: "/v1/employees/:id/zones/:zone_id", Handler: employeehandler.RemoveZoneHandler(serverCtx)},
            }...,
        ),
    )
}

Advantage: You can see every single endpoint the service exposes in one file. No hunting through 20 controllers.


9. Dependency Injection

Spring Boot — Automatic DI via IoC container

@SpringBootApplication
fun main(args: Array<String>) {
    runApplication<Application>(*args)
    // Spring scans all @Component, @Service, @Repository, @Controller
    // Wires everything automatically via @Autowired
}

@Service
class CreateEmployeeService(
    private val employeeRepository: EmployeeRepository,  // auto-injected
    private val zoneRepository: ZoneRepository           // auto-injected
)

Go — Manual DI via ServiceContext

// internal/svc/servicecontext.go ← the "ApplicationContext" of this service
type ServiceContext struct {
    Config          config.Config
    DB              *gorm.DB
    ScopeMiddleware rest.Middleware
    EmployeeModel   model.EmployeeModel       // ← like @Autowired EmployeeRepository
    ZoneModel       model.ZoneModel
    EZAModel        model.EmployeeZoneAssignmentModel
}

func NewServiceContext(c config.Config, db *gorm.DB) *ServiceContext {
    return &ServiceContext{
        Config:        c,
        DB:            db,
        EmployeeModel: model.NewEmployeeModel(db),   // ← you wire it yourself
        ZoneModel:     model.NewZoneModel(db),
        EZAModel:      model.NewEmployeeZoneAssignmentModel(db),
    }
}

// sample.go — entry point
func main() {
    db  := config.InitializeDatabase(c)  // like @Bean DataSource
    ctx := svc.NewServiceContext(c, db)  // like ApplicationContext
    handler.RegisterHandlers(server, ctx)
    server.Start()
}

Go has no DI framework. ServiceContext is passed to every Logic and every Handler. Adding a new dependency? Add it to ServiceContext and pass it through. Simple.


10. Configuration

Spring Boot

# application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/company_crm
    username: postgres
    password: secret
  jpa:
    show-sql: true

server:
  port: 8080

custom:
  identity-service-url: http://identity-service
@ConfigurationProperties(prefix = "custom")
data class AppConfig(val identityServiceUrl: String)

Go (go-zero)

# etc/company-crm-api-local.yaml
Name: company-crm-api
Host: 0.0.0.0
Port: 5000
Environment: dev
DatabaseName: company_crm

Company:
  Datasource:
    EmployeeDb:
      Url: localhost:5432
      Username: postgres
      Password: secret
// internal/config/config.go
type Config struct {
    rest.RestConf                    // Name, Host, Port embedded
    Environment  string
    DatabaseName string
    Company       struct {
        Datasource struct {
            EmployeeDb struct {
                URL      string
                Username string
                Password string
            }
        }
    } `json:",optional"`
}

// Loading (replaces @ConfigurationProperties)
conf.MustLoad(*configFile, &c)  // crashes fast if config is missing — intentional

11. Error Handling

This is the biggest conceptual shift from Spring to Go.

Spring Boot — Exceptions

// Throw anywhere, catch at global handler
@Service
class GetEmployeeService(private val repo: EmployeeRepository) {
    fun getEmployee(id: UUID): EmployeeResponse {
        return repo.findById(id)
            .orElseThrow { NotFoundException("Employee $id not found") }
            .toResponse()
    }
}

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(NotFoundException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleNotFound(ex: NotFoundException) = ErrorResponse(ex.message)
}

Go — Errors Are Values

// Errors are returned, not thrown
func (l *GetEmployeeLogic) GetEmployee(req *types.GetEmployeeRequest) (*types.EmployeeResponse, error) {
    emp, err := l.svcCtx.EmployeeModel.FindByID(l.ctx, req.ID)
    if err != nil {
        return nil, err  // caller handles it
    }
    return toEmployeeResponse(emp), nil
}

// Handler handles the error — like @ExceptionHandler but inline
func GetEmployeeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        resp, err := l.GetEmployee(&req)
        if err != nil {
            httpx.ErrorCtx(r.Context(), w, err)  // writes error JSON response
            return
        }
        httpx.OkJsonCtx(r.Context(), w, resp)
    }
}

Error Handling Rules

// Rule 1: Always check errors — ignoring is a bug
result, err := someOperation()
if err != nil {
    return nil, err
}

// Rule 2: Wrap errors with context
return nil, fmt.Errorf("finding employee %s: %w", id, err)

// Rule 3: Sentinel errors for known cases
var ErrNotFound = gorm.ErrRecordNotFound

if errors.Is(err, model.ErrNotFound) {
    // handle not found specifically
}

12. Middleware / Filters

Spring Boot

@Component
class AuthFilter : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain
    ) {
        val token = request.getHeader("Authorization")
        if (!isValid(token)) {
            response.status = 401
            return
        }
        chain.doFilter(request, response)
    }
}

// Or AOP:
@Aspect @Component
class LoggingAspect {
    @Around("@annotation(Logged)")
    fun logExecution(jp: ProceedingJoinPoint): Any { ... }
}

Go — Middleware Function

// internal/middleware/scopemiddleware.go
type ScopeMiddleware struct{ env string }

func (m *ScopeMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if m.env == "prod" {
            token := r.Header.Get("Authorization")
            if !isValid(token) {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }
        }
        next(w, r)  // like chain.doFilter()
    }
}

// Applied in routes.go:
rest.WithMiddlewares(
    []rest.Middleware{serverCtx.ScopeMiddleware},
    routes...,
)

13. HTTP Client (Feign → Go)

Spring Boot — Feign Client

@FeignClient(name = "identity-service", url = "\${identity.service.url}")
interface IdentityClient {
    @GetMapping("/v1/users/{id}")
    fun getUser(@PathVariable id: UUID): UserResponse

    @PostMapping("/v1/users")
    fun createUser(@RequestBody request: CreateUserRequest): UserResponse
}

// Usage — Spring injects automatically
@Service
class EmployeeService(private val identityClient: IdentityClient)

Go — HTTP Client Struct

// No Feign equivalent built-in — write a thin client wrapper

type IdentityClient struct {
    baseURL    string
    httpClient *http.Client
}

func NewIdentityClient(baseURL string) *IdentityClient {
    return &IdentityClient{
        baseURL:    baseURL,
        httpClient: &http.Client{Timeout: 10 * time.Second},
    }
}

func (c *IdentityClient) GetUser(ctx context.Context, id string) (*UserResponse, error) {
    url := fmt.Sprintf("%s/v1/users/%s", c.baseURL, id)
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("calling identity service: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusNotFound {
        return nil, ErrNotFound
    }
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("identity service returned %d", resp.StatusCode)
    }

    var user UserResponse
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("decoding response: %w", err)
    }
    return &user, nil
}

// Add to ServiceContext like any other dependency
type ServiceContext struct {
    IdentityClient *client.IdentityClient
    // ...
}

14. Database Migrations

Both Spring and this project use Liquibase. Syntax is identical.

Spring Boot (Flyway alternative)

-- src/main/resources/db/migration/V1__create_employees.sql
CREATE TABLE employees (
    id          UUID PRIMARY KEY,
    user_id     UUID NOT NULL UNIQUE,
    designation VARCHAR(50) NOT NULL
);

This Project (Liquibase)

-- database/migrations/common/002-create-employees.sql
--liquibase formatted sql
--changeset company:002-create-employees
CREATE TABLE IF NOT EXISTS employees (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL,
    designation TEXT NOT NULL,
    manager_id  UUID REFERENCES employees(id) ON DELETE RESTRICT,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_employees_user_id UNIQUE (user_id)
);

Run migrations:

docker compose up liquibase   # equivalent to running Flyway in Spring

Note: We do NOT use GORM's AutoMigrate. Always use explicit SQL migrations — easier to review, version, and roll back.


15. Go Concepts That Have No Spring Equivalent

Context (context.Context)

Every operation that touches the network or DB takes a ctx. It carries:

  • Cancellation — if the HTTP request times out, ctx is cancelled, DB queries abort
  • Tracing — request IDs and trace spans propagate automatically
  • Deadlines — you can set "this operation must finish in 5 seconds"
// Always pass ctx through — don't ignore it
func (m *gormEmployeeModel) FindByID(ctx context.Context, id string) (*Employee, error) {
    return ..., m.db.WithContext(ctx).First(&emp, "id = ?", id).Error
    //               ^^^^^^^^^^^^^ cancellation propagates to DB query
}

Spring handles this with @Async, @Transactional propagation, and Reactor — all implicit. In Go it's always explicit.

Goroutines

Like threads but managed by Go's runtime, extremely lightweight (2KB stack vs 1MB for JVM threads).

// Run something in the background
go func() {
    // this runs concurrently
    sendNotification(employeeID)
}()

// Spring equivalent: @Async method

Pointers for Optional Values

Go has no Optional<T> or null objects. Use pointers:

type Employee struct {
    ManagerID *string  // nil = no manager (like Optional.empty())
                       // &"some-uuid" = has manager (like Optional.of())
}

// Check nil before using
if emp.ManagerID != nil {
    manager, err := l.svcCtx.EmployeeModel.FindByID(ctx, *emp.ManagerID)
}

Interfaces Are Implicit (Duck Typing)

// Define interface
type EmployeeModel interface {
    FindByID(ctx context.Context, id string) (*Employee, error)
}

// Implement it — no "implements" keyword needed
type gormEmployeeModel struct{ db *gorm.DB }

func (m *gormEmployeeModel) FindByID(ctx context.Context, id string) (*Employee, error) {
    // ...
}
// gormEmployeeModel automatically satisfies EmployeeModel
// The compiler checks this — if a method is missing, it's a compile error

In Spring you write class EmployeeServiceImpl : EmployeeService. In Go, if a type has the right methods, it already satisfies the interface — no declaration needed.


16. Quick Reference Cheat Sheet

Terminology

Spring Boot Go (this project)
@RestController handler/ package
@Service logic/ package
@Repository model/ package (interface)
@Entity GORM struct
@Autowired ServiceContext field
ApplicationContext ServiceContext
@SpringBootApplication + main() sample.go main()
application.yml etc/*.yaml
@ExceptionHandler httpx.ErrorCtx() in handler
@Transactional db.Transaction(func(tx){...})
Optional<T> *T (pointer, check for nil)
throws Exception return nil, err
@Aspect / Filter Middleware function
Feign Client HTTP client struct
pom.xml go.mod
Flyway/Liquibase Liquibase (same)

Common GORM Operations (vs Spring Data JPA)

// findById
db.First(&emp, "id = ?", id)

// findAll
db.Find(&employees)

// findAll with condition
db.Where("designation = ?", "TL").Find(&employees)

// save (insert or update)
db.Create(&emp)    // INSERT
db.Save(&emp)      // UPDATE (full record)
db.Updates(&emp)   // UPDATE (only non-zero fields)

// delete
db.Delete(&emp)

// with associations (like .fetch = EAGER)
db.Preload("Manager").Preload("Zones").First(&emp, "id = ?", id)

// transaction (like @Transactional)
db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&emp).Error; err != nil {
        return err  // auto-rollback
    }
    return tx.Create(&eza).Error
})

Running the Service

# Start dependencies
docker compose up postgres

# Run migrations
make migrate

# Start the service (dev mode)
make dev
# or: go run sample.go -f etc/company-crm-api-local.yaml

# Build binary (like mvn package)
go build -o company-crm-service .

Last updated: April 2026 — company-crm-service v1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment