88 A Scalable graphql-go Project Structure
88 A Scalable graphql-go Project Structure: A Guide for Backend Engineers
Building an API with GraphQL in Go offers clear advantages in scalability, maintainability, and developer experience. However, the wrong project structure can make a codebase hard to maintain even before traffic ramps up. That is exactly why we need a solid project-structure strategy.
This article takes a practical look at an 88-style scalable and maintainable GraphQL-Go project structure, based on real experience and scenarios from mid-sized to enterprise-scale projects. I have also included code snippets, flow simulations, and mermaid diagrams to make the concepts easy to digest.
Why Structure Matters?
GraphQL’s dynamic, nested contract forces us to keep our code modular and easy to customize. In Go, we also have to keep separation of concerns and type safety in mind. If you are not careful, you will quickly end up with bloated resolver files, tangled model or schema files, and unclear dependencies.
A good structure gives you:
- Encapsulation and isolation of business logic
- Easier testing and faster development of new features
- Scalability as you add more team members/engineers
- Easy integration with other systems (REST, gRPC, message bus, etc.)
Anatomy: A Modern GraphQL-Go Project Structure
Below is the directory structure (📁 for folders, 📄 for files):
1📁 graph
2 📁 schema
3 📄 schema.graphqls
4 📄 directives.graphqls
5 📁 resolver
6 📄 user_resolver.go
7 📄 product_resolver.go
8 📁 generated
9 📄 generated.go
10 📄 handler.go
11 📄 loader.go
12📁 domain
13 📁 user
14 📄 model.go
15 📄 repository.go
16 📄 service.go
17 📄 service_test.go
18 📁 product
19 📄 model.go
20 📄 repository.go
21 📄 service.go
22📁 pkg
23 📄 logger.go
24 📄 config.go
25 📄 middleware.go
26 📄 util.go
27📁 cmd
28 📄 main.go
29📁 migration
30 📄 001_init.sql
31 📄 002_add_user.sql
32....Table 1: Folder Explanation
| Folder | Brief explanation |
|---|---|
| graph | Everything directly related to GraphQL (schema, loader, handler) |
| domain | Modular business logic per entity/function |
| pkg | Shared library/utility functions |
| cmd | Application entry point |
| migration | Database migration SQL |
Execution Flow of the me Query (Simulation)
The following diagram helps you understand the flow of a me GraphQL request (get current user):
graph LR
A[Client] --> B(GraphQL Handler)
B --> C(Auth Middleware)
C --> D(UserResolver.me)
D --> E(UserService.GetUserByID)
E --> F(UserRepository.FindByID)
F --> |Database| G((Postgres))
G --> F
F --> E
E --> D
D --> C
C --> B
B --> A
Deep Dive: Walking Through the Structure
1. graph/schema/*.graphqls
Separate your schema and directive definitions. For example:
1# graph/schema/schema.graphqls
2type User {
3 id: ID!
4 name: String!
5 email: String!
6}
7
8type Query {
9 me: User!
10}
11
12type Mutation {
13 register(name: String!, email: String!, password: String!): User!
14}2. graph/resolver/*.go
Resolvers should not carry too much business logic. Their only job is mapping and a little validation.
1// graph/resolver/user_resolver.go
2func (r *queryResolver) Me(ctx context.Context) (*model.User, error) {
3 userID := GetUserIDFromContext(ctx)
4 user, err := r.UserService.GetUserByID(ctx, userID)
5 if err != nil {
6 return nil, err
7 }
8 return user, nil
9}3. domain/user/model.go
Entity model definitions. There must be no dependency on the GraphQL package.
1type User struct {
2 ID string
3 Name string
4 Email string
5 Password string
6}4. domain/user/repository.go
The abstraction over the database. Ideally use an interface so it is testable.
1type UserRepository interface {
2 FindByID(ctx context.Context, id string) (*User, error)
3 FindByEmail(ctx context.Context, email string) (*User, error)
4 Create(ctx context.Context, user *User) error
5}5. domain/user/service.go
Put your business logic here.
1type UserService struct {
2 repo UserRepository
3}
4
5func (s *UserService) GetUserByID(ctx context.Context, id string) (*User, error) {
6 return s.repo.FindByID(ctx, id)
7}6. pkg/
Put shared libraries here, for example env-based config, a logger, and middleware.
1// pkg/logger.go
2func NewLogger() *zap.Logger {
3 logger, _ := zap.NewProduction()
4 return logger
5}Modularization Tips for 88+ Endpoints
Why do I call it an 88 structure? Because each domain can represent one or several GraphQL endpoints. A good practice is to separate the resolver, service, repository, and model per domain. For complex business APIs (for example an e-commerce app: user, product, order, payment, shipping, and so on), you can keep adding folders inside domain/*.
Example of adding a new domain
1📁 domain
2 📁 payment
3 📄 model.go
4 📄 repository.go
5 📄 service.go
6 ...Code Generation: Automating with gqlgen
gqlgen
is very popular for generating GraphQL code in Go with type safety. Place the configuration at the root (gqlgen.yml) and use the graph/generated folder.
Keep in mind:
The generated folder and the files inside it should never be edited by hand. All custom logic belongs in the resolver/service/domain.
Production Deployment Life Cycle
A typical deployment pipeline flow:
graph LR
A[Engineer Push Code] --> B[Run Unit Test]
B --> C[Build Binary]
C --> D[Run Integration Test]
D --> E[Create Container Image]
E --> F[Deploy ke Kubernetes/VM]
Testing: Layered Unit Tests
- Test business logic (in
/domain/*/service_test.go) - Test resolver integration (using a test client, e.g. gqlgen)
- Test middleware and utilities (in
/pkg)
A simple unit test simulation
1func TestUserService_GetUserByID(t *testing.T) {
2 mockRepo := new(MockUserRepo)
3 mockRepo.On("FindByID", mock.Anything, "123").Return(&User{ID:"123", Name:"Budi"}, nil)
4 s := NewUserService(mockRepo)
5 user, err := s.GetUserByID(context.TODO(), "123")
6 require.NoError(t, err)
7 require.Equal(t, "Budi", user.Name)
8}Checklist for a Good GraphQL-Go Project Structure
| Check | Explanation |
|---|---|
| Separate schema | Split .graphqls files per domain |
| Thin resolver | Resolvers carry minimal business logic, only delegation |
| Modular domain | Each domain has its own model, repo, and service |
| Reusable pkg | Utility libraries do not depend on any specific domain |
| Dependency inj. | Services and repos use injection for easier testing/migration |
| Automated test | Unit and integration tests are available |
| Configurable | Configuration (env) is centralized and easy to change |
| CI/CD | Supports a build/test/deploy pipeline |
Closing
A scalable project structure is not just about “having lots of folders”; it is about the discipline of separating concerns while keeping the codebase practically maintainable. With an 88-domain modular structure, you can tame the complexity of a GraphQL-Go API with a large team while still being able to iterate quickly during development.
My tip: Start with a minimal structure that is ready to grow, and always refactor whenever a new pain point appears.
Does your GraphQL-Go project structure already look like this?
Summary
- Modularity and separation of concerns are the foundation of scalability
- Separate resolvers, domain logic, utilities, and the schema
- Test coverage and deployment automation are absolutely essential
- Do not hesitate to split domains once the number of resolvers grows large
Let’s discuss:
What has your experience been with structuring GraphQL projects in Go?
Share it in the comments!