Why You Keep Switching Frameworks (and Why It Doesn't Help)
Every few months, a new Go web framework appears with promises of better performance, simpler routing, or more middleware. Many developers, frustrated with their current codebase, jump to the next shiny tool, only to find the same maintenance nightmares six months later. The cycle repeats: you blame the framework, switch, and eventually feel trapped again. But the real culprit is rarely the framework itself. It's the lack of a coherent project structure that separates concerns, enables testability, and allows independent evolution of components.
Consider a typical scenario: a team builds a REST API using Gin. Handlers contain database queries, business logic, and HTTP serialization all mixed together. After a few months, the code becomes hard to change. A new developer suggests switching to Echo because it has better validation support. The team rewrites everything, but the same soup of responsibilities emerges. Three months later, they talk about trying Fiber for its speed. The problem is not Gin, Echo, or Fiber—it's the absence of a layered architecture that isolates each concern.
Framework hopping is a symptom, not a solution. It wastes time, introduces migration bugs, and never addresses the root cause: your project structure doesn't scale. Without clear boundaries, any framework becomes a mess. With a solid structure, you can swap frameworks with minimal pain. In this guide, we'll show you how to build that foundation.
The Hidden Cost of Constant Framework Switching
Every time you switch frameworks, you incur hidden costs beyond migration effort. Your team loses context on idiomatic patterns unique to the previous framework. Newcomers must learn a different routing style, middleware chain, and error handling approach. The cognitive overhead grows exponentially with team size. Meanwhile, the fundamental architectural debt remains untouched. One team I observed spent six months migrating from Gin to Fiber, only to discover that their handler-centric design still caused merge conflicts and testing nightmares. The framework change was cosmetic.
Additionally, framework hopping often masks a deeper issue: lack of architectural vision. Teams that lack a consistent pattern for organizing code tend to chase productivity gains from framework features that promise to reduce boilerplate. But boilerplate is not the enemy; tight coupling is. A well-structured project may have more files, but each file has a single responsibility, making it easier to reason about and test. The time spent on framework evaluation would be better invested in designing a clean package hierarchy.
In short, stop treating frameworks as silver bullets. The next section outlines a structural approach that works regardless of which framework you choose.
The Core Problem: Missing Layers and Tight Coupling
At the heart of every messy Go project is a lack of clear layering. Business logic, data access, HTTP handling, and configuration all jumbled together. This is often called the "big ball of mud" pattern. It starts innocently: you put a few handler functions in a single file, add a model struct, and query the database directly. As the project grows, you add more handlers, more models, and more direct database calls. Soon, every change touches multiple files, tests become integration tests (if they exist at all), and the fear of breaking something stops the team from refactoring.
Layering is not about adding complexity; it's about managing it. A typical three-layer architecture for a web application includes: a handler layer (HTTP concerns), a service layer (business logic), and a repository layer (data access). Each layer depends only on the layer below it, and dependencies are injected rather than hard-coded. This separation allows you to test business logic without HTTP servers or databases, and swap database drivers without touching handlers.
Yet many Go developers resist layering because they think it's unnecessary for small projects or that it adds boilerplate. They prefer the simplicity of a single package with a few files. But that simplicity is an illusion: as the project grows, the cost of change increases quadratically. A small upfront investment in separation pays dividends in maintainability.
Common Symptoms of Tightly Coupled Code
How do you know your project has a layering problem? Here are telltale signs: your handler functions contain SQL queries or call database methods directly. Your service functions accept a database connection as a parameter instead of an interface. Your test files import database drivers and start a test container just to test a simple business rule. Your team spends more time debugging integration issues than writing new features. If any of these sound familiar, your structure is the culprit, not your framework.
A composite scenario: a team built a Go microservice using Chi. They placed all logic in the main package because it was "just a small service." Six months later, the service had grown to 10,000 lines. The single file became unmanageable. They split it into multiple files within the same package, but the coupling remained. When they needed to add a new feature, they had to understand the entire codebase. They considered rewriting in a different framework, but the real fix was to extract layers.
Fixing the structure doesn't require a rewrite. You can start by identifying boundaries. Group functions by responsibility: move database queries to a repository package, business rules to a service package, and HTTP handling to a handler package. Introduce interfaces to decouple layers. This incremental refactoring can be done while keeping the same framework, and it dramatically improves maintainability.
Three Structural Patterns Compared: Which One Fits Your Project?
There is no one-size-fits-all project structure, but three patterns are widely used in Go: the Standard Go Project Layout, Clean Architecture (with hexagonal influences), and the Flat Package approach. Each has trade-offs. Understanding them helps you choose wisely and avoid future framework hopping.
| Pattern | Pros | Cons | Best For |
|---|---|---|---|
| Standard Go Project Layout | Widely recognized; separates cmd/, internal/, pkg/; clear dependency direction. | Can feel overly formal for small projects; some controversy about pkg/ naming. | Projects with multiple binaries or libraries; teams that value convention. |
| Clean Architecture | Strong separation of concerns; business logic independent of frameworks; highly testable. | More boilerplate; steep learning curve; may over-engineer simple apps. | Complex business domains; projects that expect long maintenance. |
| Flat Package | Simple; minimal indirection; fast to start. | Coupling grows rapidly; hard to test; scales poorly. | Prototypes; very small services (under 500 lines). |
Many teams start with Flat Package because it's quick, but as the codebase grows, they hit the same wall. Instead of refactoring, they switch frameworks, hoping the new one will enforce better structure—but no framework can compensate for a missing architecture. The Standard Go Project Layout is a solid middle ground. It provides a clear directory structure without enforcing a strict domain model. Clean Architecture is more rigorous and is excellent for projects with complex business rules that must be tested independently of web frameworks.
Choosing Based on Team Experience
Your team's familiarity with Go patterns matters. If your team is new to Go, starting with the Standard Go Project Layout is safer. It's well-documented and many open-source projects use it. For experienced teams with a strong testing culture, Clean Architecture can pay off. The key is to choose one pattern and stick with it. Consistent structure reduces cognitive load and makes code reviews faster. Switching structures is as disruptive as switching frameworks, so commit to a pattern and evolve it incrementally.
In the next section, we'll walk through a step-by-step process to implement a layered structure using the Standard Go Project Layout as a template.
Step-by-Step: Building a Framework-Agnostic Project Structure
Let's implement a practical project structure that works with any Go web framework. We'll use the Standard Go Project Layout as a base, but adapt it to emphasize layering. The goal is to create a structure where business logic is framework-agnostic and can be tested without HTTP or database dependencies.
- Create the top-level directories:
cmd/(application entry points),internal/(private packages),pkg/(shared packages, optional). - Inside
internal/, create layers:handler/,service/,repository/, anddomain/(ormodel/). - Define domain interfaces: In
domain/, define core entities and repository interfaces. For example, aUserstruct and aUserRepositoryinterface with methods likeFindByID. - Implement repository: In
repository/, create a concrete implementation that uses a database driver. This package depends ondomainand a database package. - Implement service: In
service/, define business logic that uses the repository interface. The service struct receives the repository via dependency injection. - Implement handler: In
handler/, create HTTP handlers that call the service. Handlers parse request data, call service methods, and format responses. They know nothing about databases. - Wire dependencies in
cmd/: In the main function, initialize the database connection, create repository and service instances, and pass them to handlers. This is the composition root. - Add middleware globally: Use framework-specific middleware for logging, recovery, and authentication, but keep them thin and delegate to services where possible.
Concrete Example: User Registration Flow
Imagine a user registration endpoint. In a tightly coupled project, the handler would parse the request, validate fields, hash the password, insert into the database, and return a response—all in one function. In the layered structure, the handler only parses the request and calls service.RegisterUser(ctx, dto). The service validates business rules (e.g., email unique), calls repository.Create(user), and returns a result. The handler then formats the HTTP response. If you later change from PostgreSQL to MongoDB, you only update the repository implementation, not the service or handler. If you switch from Gin to Echo, you only rewrite the handler package (and adapt middleware), while services and repositories remain untouched.
This separation is powerful. It means you can test business logic with mocks without starting a server. It also means you can reuse services across different transports (HTTP, gRPC, CLI). The structure is the enabler, not the framework.
Common Mistakes and How to Avoid Them
Even with a good structure, teams make mistakes that undermine the benefits. Here are the most common pitfalls and how to avoid them.
Mistake 1: Leaking Framework Types into Business Logic
One of the worst violations is importing framework types (like gin.Context or echo.Context) into your service layer. This ties your business logic to a specific HTTP framework, defeating the purpose of layering. Instead, define your own request/response types in the domain layer, and have the handler translate between HTTP types and domain types.
For example, instead of service.RegisterUser(c *gin.Context), define service.RegisterUser(ctx context.Context, req RegisterUserRequest) (User, error). The handler extracts data from the Gin context, builds the request, and calls the service. This keeps the service framework-agnostic and testable with a simple context.Background().
Mistake 2: Over-Abstracting Too Early
Another mistake is creating interfaces for everything before you have multiple implementations. Premature abstraction adds complexity without benefit. A common pattern is to define a repository interface in the domain package, but only implement it once. That's fine—the interface exists to decouple the service from the implementation, not to enable polymorphism. Wait until you have a second implementation (e.g., a mock for testing) before worrying about interface design.
Similarly, don't create a separate interface for every service. Use exported structs with methods; interfaces can be defined by consumers if needed. Go's implicit interface satisfaction makes this natural.
Mistake 3: Ignoring Dependency Injection
Dependency injection (DI) is crucial for testability and decoupling. Yet many teams either avoid DI or use it incorrectly. The simplest form is manual DI: you create dependencies in main and pass them down. Avoid global variables or init functions that set up dependencies—they make testing impossible. For larger projects, consider using a DI container like Google Wire, which generates wiring code at compile time.
Without DI, your services will call repository.New() directly, making them dependent on concrete implementations. With DI, they receive an interface, and you can swap implementations for testing or replacement.
Mistake 4: Mixing Config with Logic
Hardcoding configuration values (database URLs, API keys) inside packages is a common error. Use a dedicated config package that reads from environment variables or a config file, and pass the config to the relevant packages during initialization. This keeps your code portable and secure.
Avoid importing config directly in services or repositories; instead, inject the specific values they need. For example, pass a DatabaseDSN string to the repository constructor, not the entire config struct.
Mini-FAQ: Common Questions About Project Structure
Q: Should I use a framework at all, or just the standard library? A: It depends. The standard library has improved significantly since Go 1.22, with better routing. For small APIs, it may be enough. For larger projects, a framework provides middleware, context management, and routing conveniences that reduce boilerplate. The key is to choose one and stick with it, while ensuring your business logic is framework-agnostic.
Q: How do I handle database migrations in a layered structure? A: Migrations are a separate concern. Use a tool like golang-migrate or pressly/goose. Run migrations during deployment, not in application code. Your repository layer assumes the schema exists; migrations are infrastructure.
Q: My project is already a mess. Should I rewrite it? A: Rarely. Rewriting is tempting but risky. Instead, refactor incrementally. Start by extracting a domain package with core types and interfaces. Then move database logic to a repository package, one endpoint at a time. Gradually introduce services. You can do this while the project is live, using feature toggles or parallel runs.
Q: What about microservices? Does the same advice apply? A: Yes. Each microservice should have its own project structure following same principles. In fact, microservices benefit even more because each service is smaller, but without structure they become unmanageable. Use the same layering within each service.
Q: How do I test services without a database? A: Use interfaces. In tests, provide a mock repository that returns predefined data. The service layer doesn't know the difference. This allows fast unit tests that don't require infrastructure.
Q: Should I use a DI container? A: Manual DI works for most projects. If you have many dependencies and want compile-time safety, consider Google Wire. Avoid runtime DI frameworks that use reflection—they add complexity and can hide wiring errors.
Synthesis: Your Next Steps to Break the Cycle
Framework hopping is a costly habit that never addresses the real issue: lack of a coherent project structure. By investing in a layered architecture with clear separation of concerns, you make your codebase resilient to framework changes and easier to maintain. The steps are straightforward: define layers (handler, service, repository), use dependency injection, keep business logic framework-agnostic, and test with mocks.
Start small. Pick one existing endpoint and refactor it into the layered pattern. Learn from that experience. Then expand to the rest of the codebase. You don't need to rewrite everything at once. The goal is to create a structure that makes framework choice irrelevant. When you finally decide to switch frameworks (if ever), you'll only need to rewrite the handler layer, and your services and repositories will transfer seamlessly.
Remember, the best framework is the one your team knows well and that supports your chosen architecture. Stop hopping, start structuring. Your future self—and your teammates—will thank you.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!