Skip to content

Instantly share code, notes, and snippets.

@rexlow
Created November 18, 2025 09:43
Show Gist options
  • Select an option

  • Save rexlow/c3800212e6634fb836a749787489586f to your computer and use it in GitHub Desktop.

Select an option

Save rexlow/c3800212e6634fb836a749787489586f to your computer and use it in GitHub Desktop.
Java vs Go

Java Spring Boot vs Go: A Comparison Guide

🎯 TL;DR - When to Use What

Use Java/Spring Boot when:

  • Traditional monolithic enterprise apps with complex business logic
  • Existing Java investment - teams, infrastructure, codebase
  • Specific enterprise integrations - SAP, Oracle, legacy JVM systems
  • Advanced ORM needs - Complex object graphs, inheritance, cascading operations
  • Regulated industries - Certified Java stacks required

Use Go when:

  • Microservices architecture - Independent, distributed services
  • Cloud-native applications - Kubernetes, containers, serverless
  • Performance critical - Low latency, high throughput requirements
  • Operational simplicity - Single binary deployment, no JVM tuning
  • Cost optimization - Lower infrastructure costs at scale

⚠️ Common Misconception

Myth: "Java is for large enterprise, Go is for small startups"
Reality: Both handle large-scale applications. Architecture pattern matters more than language.

Real-world Go enterprises:

  • Google (infrastructure), Uber (1000+ microservices), Dropbox (performance layer)
  • Netflix (infrastructure), PayPal (payment processing), American Express (payments)

Overview

Aspect Java/Spring Boot Go
Philosophy Convention over configuration Explicit over implicit
Type System Object-oriented, classes Structs + interfaces
Concurrency Threads, CompletableFuture Goroutines, channels
Dependency Management Maven/Gradle go mod
Web Framework Spring Boot (opinionated) Standard library + frameworks (gin, echo, chi)

Decision Matrix

Choose Java/Spring Boot for:

Existing Investment

  • ✅ Large existing Java codebase
  • ✅ Team expertise heavily in Java/JVM ecosystem
  • ✅ Integration with legacy Java systems (SAP, Oracle, IBM)
  • ✅ Regulatory compliance requiring certified Java stacks

Ecosystem Requirements

  • ✅ Need specific Spring ecosystem tools (Spring Security, Spring Cloud)
  • ✅ Rich ORM with complex object graphs
  • ✅ Enterprise-grade APM tools (Dynatrace, AppDynamics)

Choose Go for:

Performance & Scale

  • ✅ High-performance requirements (low latency, high throughput)
  • ✅ Cost optimization at scale (lower memory/CPU usage)
  • ✅ Fast startup times critical (containers, auto-scaling)
  • ✅ Efficient concurrency with thousands of connections

Operational Excellence

  • ✅ Simple deployment (single binary, no runtime dependencies)
  • ✅ Easy to understand codebase (minimal "magic")
  • ✅ Fast onboarding for new developers
  • ✅ Predictable performance (no JVM tuning needed)

Real-World Examples

Java/Spring Boot Success Cases

  • Banking systems - Complex transactions, regulatory compliance
  • E-commerce platforms - Rich domain models, complex business rules
  • ERP systems - Deep object hierarchies, extensive integrations
  • Legacy modernization - Gradual migration from old Java systems

Go Success Cases

  • Uber - 1000+ microservices handling millions of requests
  • Dropbox - Performance-critical file sync engine
  • Cloudflare - Edge computing, DNS, CDN infrastructure
  • PayPal - High-volume payment processing services
  • Netflix - Infrastructure services, API gateways

Complexity Comparison

Feature Java/Spring Go
Startup time Slow (5-10s) Fast (<1s)
Memory usage High (200MB+) Low (20MB+)
Build time Slow Very fast
Learning curve Steep Gentle
Debugging Hard (magic) Easy (explicit)
Performance Good Excellent
Concurrency Complex Simple (goroutines)

The "Magic" Factor

Java/Spring Philosophy: "Don't repeat yourself" - framework does heavy lifting

  • Annotations trigger behaviors
  • Scanning and reflection at startup
  • Conventions reduce boilerplate
  • Result: Less code, but harder to trace

Go Philosophy: "Explicit is better than implicit"

  • Clear execution flow
  • No hidden behaviors
  • Manual wiring
  • Result: More code, but easier to understand

Bottom Line

It's Not About "Enterprise vs Startup"

Both languages handle large-scale applications successfully. The choice depends on:

  1. Architecture Pattern

    • Monolithic → Java/Spring often better
    • Microservices → Go often better
  2. Team & Organization

    • Existing Java investment → Stay with Java
    • New team/greenfield → Either works, choose based on expertise
  3. Technical Requirements

    • Complex ORM needs → Java/Hibernate
    • Performance critical → Go
    • Enterprise middleware → Java
    • Cloud-native → Go

Don't choose based on company size. Choose based on architecture, team, and requirements.


📚 Detailed Code Comparisons

Click to expand: Project Setup

Java/Spring Boot

<!-- pom.xml - Maven dependency file -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Build & Run:

mvn spring-boot:run
mvn clean install

Go

// go.mod
module myapp

go 1.21

require (
    github.com/gin-gonic/gin v1.9.0
    gorm.io/gorm v1.25.0
)

Build & Run:

go run main.go
go build
Click to expand: Dependency Injection

Java/Spring Boot

@Service
@RequiredArgsConstructor  // Lombok generates constructor
public class UserService {
    private final UserRepository userRepository;  // Auto-injected by Spring
    private final EmailService emailService;      // Auto-injected by Spring
}

What happens:

  1. Spring scans for @Service, @Repository, @Component
  2. Creates instances (beans) in application context
  3. Automatically injects dependencies via constructor
  4. All wiring happens at startup

Go

type UserService struct {
    userRepo     *UserRepository  // Manual injection
    emailService *EmailService    // Manual injection
}

func NewUserService(repo *UserRepository, email *EmailService) *UserService {
    return &UserService{
        userRepo:     repo,
        emailService: email,
    }
}

// Manual wiring in main.go
func main() {
    db := initDB()
    repo := NewUserRepository(db)
    emailService := NewEmailService()
    userService := NewUserService(repo, emailService)
    controller := NewUserController(userService)
}

Trade-offs:

  • Spring: Less code, but "where did this come from?" debugging
  • Go: More explicit, easy to trace, but more boilerplate

REST API Routing

Java/Spring Boot

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
    
    @GetMapping
    public ResponseEntity<List<UserResponse>> getAllUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
    
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) {
        UserResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UserRequest request) {
        return ResponseEntity.ok(userService.updateUser(id, request));
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}

Magic happening:

  • Routes registered automatically from annotations
  • JSON serialization/deserialization automatic
  • Path variables extracted automatically
  • Validation runs automatically with @Valid
  • Returns converted to JSON responses

Go (with Gin)

func main() {
    r := gin.Default()
    
    userController := NewUserController(userService)
    
    api := r.Group("/api/users")
    {
        api.GET("", userController.GetAllUsers)
        api.GET("/:id", userController.GetUser)
        api.POST("", userController.CreateUser)
        api.PUT("/:id", userController.UpdateUser)
        api.DELETE("/:id", userController.DeleteUser)
    }
    
    r.Run(":8080")
}

func (c *UserController) GetUser(ctx *gin.Context) {
    id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
    if err != nil {
        ctx.JSON(400, gin.H{"error": "Invalid ID"})
        return
    }
    
    user, err := c.service.GetUserByID(id)
    if err != nil {
        ctx.JSON(404, gin.H{"error": "User not found"})
        return
    }
    
    ctx.JSON(200, user)
}

func (c *UserController) CreateUser(ctx *gin.Context) {
    var req UserRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    user, err := c.service.CreateUser(&req)
    if err != nil {
        ctx.JSON(500, gin.H{"error": err.Error()})
        return
    }
    
    ctx.JSON(201, user)
}

Explicit steps:

  • Routes registered manually
  • JSON parsing manual
  • Path parameters extracted manually
  • Error handling explicit at every step
  • Status codes set explicitly

Database Access

Java/Spring Boot (JPA/Hibernate)

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByAgeGreaterThan(Integer age);  // Method name = query
}

// Usage
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    
    public User createUser(UserRequest request) {
        User user = new User();
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        return userRepository.save(user);  // Auto INSERT/UPDATE
    }
}

JPA Magic:

  • Generates SQL schema from annotations
  • Method names converted to SQL queries
  • Auto-detects INSERT vs UPDATE
  • Lazy loading, caching, dirty checking
  • Transaction management automatic

application.properties:

spring.jpa.hibernate.ddl-auto=create-drop  # Auto creates tables
spring.jpa.show-sql=true                    # Shows generated SQL

Go (with GORM)

type User struct {
    ID        uint      `gorm:"primaryKey"`
    Name      string    `gorm:"not null"`
    Email     string    `gorm:"uniqueIndex;not null"`
    Age       int
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByEmail(email string) (*User, error) {
    var user User
    result := r.db.Where("email = ?", email).First(&user)
    return &user, result.Error
}

func (r *UserRepository) ExistsByEmail(email string) (bool, error) {
    var count int64
    err := r.db.Model(&User{}).Where("email = ?", email).Count(&count).Error
    return count > 0, err
}

func (r *UserRepository) Create(user *User) error {
    return r.db.Create(user).Error
}

// Usage in service
func (s *UserService) CreateUser(req *UserRequest) (*User, error) {
    user := &User{
        Name:  req.Name,
        Email: req.Email,
        Age:   req.Age,
    }
    
    if err := s.repo.Create(user); err != nil {
        return nil, err
    }
    
    return user, nil
}

// Manual migration
func main() {
    db.AutoMigrate(&User{})  // Creates/updates table
}

Go (with sqlx - more explicit)

func (r *UserRepository) FindByEmail(email string) (*User, error) {
    var user User
    query := `SELECT id, name, email, age, created_at FROM users WHERE email = $1`
    err := r.db.Get(&user, query, email)
    return &user, err
}

func (r *UserRepository) Create(user *User) error {
    query := `
        INSERT INTO users (name, email, age)
        VALUES ($1, $2, $3)
        RETURNING id, created_at
    `
    return r.db.QueryRow(query, user.Name, user.Email, user.Age).
        Scan(&user.ID, &user.CreatedAt)
}

Validation

Java/Spring Boot

@Data
public class UserRequest {
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    private String name;
    
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
    
    @NotNull
    @Min(value = 0, message = "Age must be positive")
    @Max(value = 150)
    private Integer age;
}

@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) {
    // Validation happens automatically
    // If fails, returns 400 with error details
}

Magic:

  • @Valid triggers validation
  • Failures throw MethodArgumentNotValidException
  • Global exception handler formats errors
  • Returns structured JSON error response

Go (with validator)

type UserRequest struct {
    Name  string `json:"name" validate:"required,min=2,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"required,min=0,max=150"`
}

var validate = validator.New()

func (c *UserController) CreateUser(ctx *gin.Context) {
    var req UserRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    // Manual validation call
    if err := validate.Struct(&req); err != nil {
        errors := make(map[string]string)
        for _, err := range err.(validator.ValidationErrors) {
            errors[err.Field()] = err.Tag()
        }
        ctx.JSON(400, gin.H{"errors": errors})
        return
    }
    
    // Continue with valid data
}

Error Handling

Java/Spring Boot

// Custom exceptions
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

// Global exception handler
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "An unexpected error occurred",
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

// Service throws exception
public UserResponse getUserById(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
    return mapToResponse(user);
}

Magic:

  • Exceptions bubble up automatically
  • @ExceptionHandler catches them globally
  • Single place to format all errors
  • Controller code stays clean

Go

// Custom error types
type NotFoundError struct {
    Message string
}

func (e *NotFoundError) Error() string {
    return e.Message
}

// Service returns error
func (s *UserService) GetUserByID(id int64) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return nil, &NotFoundError{Message: "User not found"}
        }
        return nil, err
    }
    return user, nil
}

// Controller handles error explicitly
func (c *UserController) GetUser(ctx *gin.Context) {
    id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
    
    user, err := c.service.GetUserByID(id)
    if err != nil {
        switch e := err.(type) {
        case *NotFoundError:
            ctx.JSON(404, gin.H{"error": e.Message})
        default:
            ctx.JSON(500, gin.H{"error": "Internal server error"})
        }
        return
    }
    
    ctx.JSON(200, user)
}

// Or middleware for centralized handling
func ErrorMiddleware() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        ctx.Next()
        
        if len(ctx.Errors) > 0 {
            err := ctx.Errors.Last()
            switch err.Err.(type) {
            case *NotFoundError:
                ctx.JSON(404, gin.H{"error": err.Error()})
            default:
                ctx.JSON(500, gin.H{"error": "Internal server error"})
            }
        }
    }
}

Configuration

Java/Spring Boot

# application.properties
server.port=8080
spring.application.name=demo

# Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# Profiles: application-prod.properties, application-dev.properties
spring.profiles.active=dev
@Value("${server.port}")
private int serverPort;

@Value("${spring.application.name}")
private String appName;

Go

// Using viper or environment variables
type Config struct {
    ServerPort   int    `env:"SERVER_PORT" envDefault:"8080"`
    DatabaseURL  string `env:"DATABASE_URL" envDefault:"localhost:5432"`
    AppName      string `env:"APP_NAME" envDefault:"myapp"`
}

func LoadConfig() (*Config, error) {
    cfg := &Config{}
    if err := env.Parse(cfg); err != nil {
        return nil, err
    }
    return cfg, nil
}

// Or manual
func main() {
    port := os.Getenv("SERVER_PORT")
    if port == "" {
        port = "8080"
    }
}

Boilerplate Reduction

Java (Lombok)

@Data                    // Generates: getters, setters, toString, equals, hashCode
@NoArgsConstructor       // Generates: User()
@AllArgsConstructor      // Generates: User(id, name, email)
@Builder                 // Generates: User.builder().name("John").build()
public class User {
    private Long id;
    private String name;
    private String email;
}

// Without Lombok: 100+ lines
// With Lombok: 8 lines

Go

// No equivalent - write it yourself or generate with tools
type User struct {
    ID    int64
    Name  string
    Email string
}

// Getters/setters not common in Go - direct field access
user.Name = "John"
fmt.Println(user.Name)

// Builder pattern (manual)
func NewUser() *User {
    return &User{}
}

func (u *User) WithName(name string) *User {
    u.Name = name
    return u
}

user := NewUser().WithName("John").WithEmail("[email protected]")

Testing

Java/Spring Boot

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldGetUser() throws Exception {
        UserResponse user = new UserResponse(1L, "John", "[email protected]");
        when(userService.getUserById(1L)).thenReturn(user);
        
        mockMvc.perform(get("/api/users/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.name").value("John"));
    }
}

Go

func TestGetUser(t *testing.T) {
    // Create mock service
    mockService := &MockUserService{
        GetUserByIDFunc: func(id int64) (*User, error) {
            return &User{ID: 1, Name: "John", Email: "[email protected]"}, nil
        },
    }
    
    // Setup router
    r := gin.Default()
    controller := NewUserController(mockService)
    r.GET("/api/users/:id", controller.GetUser)
    
    // Test request
    w := httptest.NewRecorder()
    req, _ := http.NewRequest("GET", "/api/users/1", nil)
    r.ServeHTTP(w, req)
    
    assert.Equal(t, 200, w.Code)
    assert.Contains(t, w.Body.String(), "John")
}

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