DEV Community

Cover image for From PHP to Go: Recognizing and Avoiding PHP-ish Patterns in Go Projects (part 2)
Mohammad Reza
Mohammad Reza

Posted on

From PHP to Go: Recognizing and Avoiding PHP-ish Patterns in Go Projects (part 2)

Overengineering: Bringing Laravel’s Complexity into Go

Introduction

In the realm of software development, choosing the right tools and frameworks is paramount for building efficient and maintainable applications. Laravel, a popular PHP framework, offers a rich set of features that can accelerate development but also introduces significant complexity. Go (Golang), in contrast, emphasizes simplicity and minimalism, encouraging developers to write clear and straightforward code.

This article explores the pitfalls of overengineering by attempting to transplant Laravel's complexity into Go. We'll focus on three critical areas where this can occur:

  1. Implementing Laravel's Service Container in Go
  2. Mimicking Eloquent ORM in Go
  3. Recreating Laravel's Routing and Middleware

By examining these areas with well-defined examples, we'll understand why embracing Go's idioms leads to better software design and how to handle common development tasks effectively in Go without unnecessary complexity.


1. Implementing Laravel's Service Container in Go

The Pitfall: Overcomplicating Dependency Injection

In Laravel: The service container is a powerful tool that manages class dependencies and performs dependency injection (DI), allowing for flexible and decoupled code.

The Overengineering Trap in Go: Attempting to replicate Laravel's service container leads to unnecessary complexity, making the code harder to understand and maintain.

Overengineered Example in Go:

// Service interface defines the behavior
type Service interface {
    PerformTask()
}

// ConcreteService implements the Service interface
type ConcreteService struct{}

func (s *ConcreteService) PerformTask() {
    fmt.Println("Task performed")
}

// ServiceContainer holds service instances
type ServiceContainer struct {
    services map[string]interface{}
}

func NewServiceContainer() *ServiceContainer {
    return &ServiceContainer{services: make(map[string]interface{})}
}

func (c *ServiceContainer) Register(name string, service interface{}) {
    c.services[name] = service
}

func (c *ServiceContainer) Get(name string) interface{} {
    return c.services[name]
}

func main() {
    container := NewServiceContainer()
    container.Register("service", &ConcreteService{})

    svc := container.Get("service").(Service)
    svc.PerformTask()
}
Enter fullscreen mode Exit fullscreen mode

Issues with This Approach:

  • Unnecessary Abstraction: Introducing a service container adds complexity without clear benefits in Go.
  • Type Safety Compromised: Using interface{} requires type assertions, leading to potential runtime errors.
  • Maintenance Overhead: The code becomes harder to read and maintain.

Embracing Go's Simplicity: Idiomatic Dependency Injection

Go favors explicitness and simplicity. Dependency injection can be achieved without a service container.

Idiomatic Example in Go:

// Service interface remains the same
type Service interface {
    PerformTask()
}

// ConcreteService implements the Service interface
type ConcreteService struct{}

func (s *ConcreteService) PerformTask() {
    fmt.Println("Task performed")
}

// Function that uses the Service interface
func ExecuteTask(svc Service) {
    svc.PerformTask()
}

func main() {
    service := &ConcreteService{}
    ExecuteTask(service)
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach:

  • Explicit Dependencies: Dependencies are passed directly, making them clear and manageable.
  • Type Safety Ensured: Interfaces provide compile-time checks.
  • Simplified Codebase: Easier to read, understand, and maintain.

2. Mimicking Eloquent ORM in Go

The Pitfall: Introducing Heavy ORM Abstractions

In Laravel: Eloquent ORM provides an active record implementation, making database interactions intuitive but heavily abstracted.

The Overengineering Trap in Go: Recreating Eloquent's functionality in Go can lead to complex code that doesn't leverage Go's strengths and may introduce performance issues.

Overengineered Example in Go:

type Model struct {
    tableName string
}

func (m *Model) Find(id int) map[string]interface{} {
    // Complex reflection and query building (hypothetical)
    return map[string]interface{}{}
}

type User struct {
    Model
    ID   int
    Name string
}

func main() {
    user := User{}
    user.tableName = "users"
    data := user.Find(1)
    fmt.Println(data)
}
Enter fullscreen mode Exit fullscreen mode

Issues with This Approach:

  • Reflection Overuse: Go's reflection is less flexible and can be inefficient compared to PHP.
  • Loss of Type Safety: Returning map[string]interface{} loses compile-time checks.
  • Increased Complexity: Obscures the underlying operations, making debugging difficult.

Embracing Go's Database Handling: Using database/sql and Lightweight Libraries

Go's standard library provides powerful tools for database interactions without heavy abstractions. By using the database/sql package along with lightweight helper libraries, you can achieve efficient database operations while maintaining simplicity.

Using database/sql Effectively

Example with database/sql:

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID   int
    Name string
}

func main() {
    // Establish database connection
    db, err := sql.Open("mysql", "user:password@/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Prepare query
    stmt, err := db.Prepare("SELECT id, name FROM users WHERE id = ?")
    if err != nil {
        log.Fatal(err)
    }
    defer stmt.Close()

    // Execute query
    user := User{}
    err = stmt.QueryRow(1).Scan(&user.ID, &user.Name)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("User: %+v\n", user)
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach:

  • Type Safety: Struct fields ensure data types are correct.
  • Performance: Direct queries are efficient and transparent.
  • Simplicity: Clear and maintainable code that leverages Go's strengths.

Using Helper Libraries like sqlx for Convenience

For developers who desire some convenience without the overhead of a full ORM, sqlx extends database/sql to make working with databases easier.

Example with sqlx:

import (
    "fmt"
    "log"

    "github.com/jmoiron/sqlx"
    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID   int    `db:"id"`
    Name string `db:"name"`
}

func main() {
    // Connect to the database
    db, err := sqlx.Connect("mysql", "user:password@/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Fetch a user
    user := User{}
    err = db.Get(&user, "SELECT id, name FROM users WHERE id = ?", 1)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("User: %+v\n", user)
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Convenience Methods: sqlx provides Get, Select, and named query support.
  • Minimal Abstraction: Enhances database/sql without hiding the underlying SQL.
  • Type Safety and Struct Scanning: Maps query results directly into structs.

Considering ORM Libraries Like GORM

If you require more abstraction and features like automatic migrations, associations, and more, Go has ORM libraries like GORM. However, it's important to weigh the benefits against the added complexity and potential performance implications.

Example with GORM:

import (
    "fmt"
    "log"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID   int
    Name string
}

func main() {
    // Setup GORM with MySQL
    dsn := "user:password@tcp(127.0.0.1:3306)/dbname"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }

    // Query using GORM
    var user User
    result := db.First(&user, 1)
    if result.Error != nil {
        log.Fatal(result.Error)
    }

    fmt.Printf("User: %+v\n", user)
}
Enter fullscreen mode Exit fullscreen mode

Considerations When Using GORM:

  • Abstraction Level: GORM provides higher-level abstractions, which can be beneficial but may hide the underlying SQL operations.
  • Performance: ORMs can introduce overhead; profiling is essential to ensure performance meets requirements.
  • Complexity vs. Convenience: Evaluate whether the features provided justify the added complexity.

Making an Informed Choice

When handling database interactions in Go, it's crucial to:

  • Understand Your Needs: Determine whether you need the features provided by an ORM or if direct SQL queries suffice.
  • Balance Abstraction and Control: Choose a tool that offers the right balance for your project's complexity and performance requirements.
  • Leverage Go's Strengths: Utilize Go's type safety and concurrency features to build robust database interactions.

3. Recreating Laravel's Routing and Middleware

The Pitfall: Overcomplicating HTTP Handling

In Laravel: Routing and middleware are handled through a sophisticated system that provides flexibility but adds layers of abstraction.

The Overengineering Trap in Go: Rebuilding this system in Go can result in unnecessary complexity and reduced performance.

Overengineered Example in Go:

type Middleware func(http.HandlerFunc) http.HandlerFunc

type Router struct {
    routes     map[string]http.HandlerFunc
    middleware []Middleware
}

func NewRouter() *Router {
    return &Router{
        routes:     make(map[string]http.HandlerFunc),
        middleware: []Middleware{},
    }
}

func (r *Router) Use(mw Middleware) {
    r.middleware = append(r.middleware, mw)
}

func (r *Router) AddRoute(path string, handler http.HandlerFunc) {
    // Apply middleware in reverse order
    for i := len(r.middleware) - 1; i >= 0; i-- {
        handler = r.middleware[i](handler)
    }
    r.routes[path] = handler
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    if handler, ok := r.routes[req.URL.Path]; ok {
        handler(w, req)
    } else {
        http.NotFound(w, req)
    }
}

func main() {
    router := NewRouter()
    router.Use(loggingMiddleware)
    router.AddRoute("/", homeHandler)

    http.ListenAndServe(":8080", router)
}
Enter fullscreen mode Exit fullscreen mode

Issues with This Approach:

  • Redundant Effort: Go's net/http package already provides routing and middleware capabilities.
  • Increased Complexity: Custom implementations can introduce bugs and maintenance challenges.

Embracing Go's net/http: Simple and Effective

Go's standard library enables building HTTP servers efficiently without extra layers.

Idiomatic Example with net/http:

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

// Middleware function
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// Handler function
func homeHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Welcome to the Home Page!")
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)

    // Wrap the mux with middleware
    handler := loggingMiddleware(mux)

    log.Println("Server starting on :8080")
    err := http.ListenAndServe(":8080", handler)
    if err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach:

  • Simplicity: Uses built-in functionalities without reinventing the wheel.
  • Flexibility: Middleware can be easily added or removed.
  • Performance: Minimal overhead ensures efficient handling of requests.

Utilizing Third-Party Routers When Needed

For more complex routing requirements, Go has third-party routers like gorilla/mux or httprouter.

Example with gorilla/mux:

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func userHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    userID := vars["id"]
    fmt.Fprintf(w, "User ID: %s\n", userID)
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/users/{id}", userHandler)

    log.Println("Server starting on :8080")
    err := http.ListenAndServe(":8080", r)
    if err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Named Parameters: Support for dynamic routes.
  • Middleware Support: Easier to apply middleware to specific routes.
  • Community Support: Widely used and maintained.

Considerations:

  • Evaluate Complexity: Ensure that adding a third-party router is justified by your application's needs.
  • Performance Implications: Third-party routers may introduce slight overhead; profile if necessary.

Conclusion

Attempting to bring Laravel's complexity into Go often results in overengineered solutions that conflict with Go's philosophy of simplicity and clarity. By embracing Go's idioms and leveraging its powerful standard library, developers can write code that is:

  • Efficient: Lean code that performs well.
  • Maintainable: Easier to read, understand, and extend.
  • Reliable: Type safety and explicit error handling reduce bugs.

Key Takeaways:

  • Understand the Language Philosophy: Go is designed for simplicity; align your coding practices accordingly.
  • Leverage Standard Libraries and Lightweight Tools: Use Go's built-in packages and consider lightweight libraries that complement the language's strengths.
  • Avoid Unnecessary Abstractions: Only introduce complexity when it provides clear benefits.
  • Embrace Explicitness: Clear and direct code enhances maintainability and reduces errors.
  • Make Informed Choices: Evaluate your application's needs to choose the right tools and patterns.

By focusing on these principles, you can avoid the pitfalls of overengineering and fully harness the power of Go to build robust, scalable applications.


Additional Resources


By understanding the contrasts between Laravel and Go, and by embracing Go's native strengths, developers can write better code and avoid the trap of overengineering. Remember, simplicity is not about doing less; it's about doing more with less complexity.

Top comments (0)