Skip to content
Santekno.com | Tech Tutorials and Trends
EN
📖 0%
15 Jul 2025 · 6 min read ·Article 15 / 125
Go

15 Good Resolver Writing Structures in Go

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

15 Good Resolver Writing Structures in Go

Go (or Golang) offers a balance between speed, ease of deployment, and a simple syntax. When building distributed systems, microservices, or GraphQL implementations, we are frequently faced with the task of building resolvers—the part that bridges requests between services, databases, and the response received by the client.

However, making a resolver simply “work” is not enough. A poorly designed resolver will quickly accumulate technical debt, make debugging difficult, and ultimately slow down the team’s development pace.

In this article, I summarize 15 good resolver writing structures in Go—based on real-world experience building medium- to large-scale backends. We’ll cover best practices, provide code examples, simulations, and simple diagrams to make everything easier to understand.


1. Define the Resolver Interface Contract

Future requirements often demand additional dependencies on a resolver. To make it easy to change and test, declare an interface:

go
1type UserResolver interface {
2    GetUser(ctx context.Context, id string) (*User, error)
3}
Danger
Tip: With an interface, testing via mocks becomes much easier.

2. The Resolver Struct Has Explicit Dependencies

Declare external dependencies (services, repositories, etc.) explicitly in the struct:

go
1type userResolver struct {
2    repo UserRepository
3    log  Logger
4}

Writing dependency injection explicitly makes tracing and refactoring easier.


3. Use Context Consistently

A widely adopted convention in Go is to place context.Context as the first parameter—important for tracing, log correlation, auth, and more:

go
1func (r *userResolver) GetUser(ctx context.Context, id string) (*User, error)

Never “skip” the context parameter.


4. Validate Input as Early as Possible

Before entering the service layer, validate the input. Use a validator helper (or packages such as go-playground/validator):

go
1if strings.TrimSpace(id) == "" {
2    return nil, errors.New("user ID required")
3}

5. Consistent Error Handling

Always handle errors at every major step. Use error wrapping so the stack trace stays clear:

go
1user, err := r.repo.FindByID(ctx, id)
2if err != nil {
3    return nil, fmt.Errorf("repo.FindByID: %w", err)
4}

Based on audit results, custom error codes and consistent wrapping speed up tracing by more than 2x.


6. Mapping Between Layers

A resolver should not directly expose entities from the repository. Always map them to a response struct:

go
1func mapUserToResponse(u *User) *UserResponse {
2    return &UserResponse{
3        ID: u.ID,
4        Name: u.Name,
5        Email: u.Email,
6    }
7}

The goal is to ensure that changes in lower layers don’t “leak” into the API response.


7. Avoid Heavy Business Logic in the Resolver Layer

A resolver is just a bridge, not a business logic factory. Call the service/business layer for the core logic.

Wrong:

go
1if user.Status == "pending" && payment.Status == "success" {
2    // ...approval logic
3}

Correct:

go
1approve, err := r.userService.CanApprove(ctx, user, payment)


8. Log Only at Important Points

Excessive logging will mask important errors. Log when an error occurs, not at every single step:

go
1log.Errorf("failed to find user: %v", err)

9. Use Clear Function Naming

Follow the {Verb}{Noun} pattern for resolver methods. For example: GetUser, UpdateUser, DeleteUser.


10. Settle on a Consistent Response

Decide on the response shape upfront and make sure it is consistent across all resolvers.

FunctionResponseError Returned
GetUser*UserResponseerror
ListUsers[]UserResponseerror
DeleteUserboolerror

This makes implementation and test automation easier for the consuming client.


11. Handle Nested Queries with a Flow Diagram

A resolver sometimes has to call multiple services. To think more clearly, create a flow diagram.

MERMAID
flowchart TD
    A[Receive GetUser Request] --> B[Validate Input]
    B -->|valid| C[Get From Repo]
    C -->|found| D[Map To Response]
    D --> E[Return Response]
    B -->|invalid| F[Return Error]
    C -->|not found| F

12. Document the Code Simply

Comment every resolver function, especially if it has edge cases or side effects:

go
1// GetUser looks up a user by ID. It returns an error if the user is not found.

13. Unit Test Every Resolver

Testing is not optional. Write at least a minimal unit test for every resolver.

go
1func TestGetUser_Success(t *testing.T) {
2    // Arrange: mock repo response
3    // Act: call resolver.GetUser
4    // Assert: correct response, nil error
5}

14. The Dependency Injection Pattern

Implement dependency injection so things are easy to swap out (for testing, for example):

go
1func NewUserResolver(repo UserRepository, log Logger) UserResolver {
2    return &userResolver{repo: repo, log: log}
3}

15. Avoid Unnecessary Side Effects

A resolver should be idempotent. Don’t perform actions that change on every call, unless it is truly necessary.

Wrong:

go
1func (r *userResolver) GetUser(ctx context.Context, id string) (*User, error) {
2    sendTrackingEvent(ctx, id) // side effect: tracking!
3    // ...
4}

Correct: Tracking like this can be attached in middleware, not in the resolver.


Case Study: A Simple Simulation

Let’s see how the good practices above are implemented in a simple resolver.

go
 1// Interface
 2type UserResolver interface {
 3    GetUser(ctx context.Context, id string) (*UserResponse, error)
 4}
 5
 6// Struct
 7type userResolver struct {
 8    repo UserRepository
 9    log  Logger
10}
11
12// Response Shape
13type UserResponse struct {
14    ID    string
15    Name  string
16    Email string
17}
18
19// Implementation
20func (r *userResolver) GetUser(ctx context.Context, id string) (*UserResponse, error) {
21    // 1. Initial validation
22    if strings.TrimSpace(id) == "" {
23        r.log.Errorf("invalid id input")
24        return nil, errors.New("user ID is required")
25    }
26
27    // 2. Fetch data from the repo
28    user, err := r.repo.FindByID(ctx, id)
29    if err != nil {
30        r.log.Errorf("error FindByID: %v", err)
31        return nil, fmt.Errorf("failed to find user: %w", err)
32    }
33
34    // 3. Map to the response
35    resp := mapUserToResponse(user)
36    return resp, nil
37}

Conclusion

Writing a good resolver in Go isn’t just about getting it to “run”—it’s about being readable, easy to test, low on bugs, not burdening the layers above or below it, and having a healthy dependency structure. By following the 15 structures above, your backend team’s work will be more sustainable, maintainable, and scalable.

A resolver is just a small part of a large system design—but if neglected, it can become a hole that leads to technical debt. Focus on the simple principles above, and your team will be more at ease facing the next project deadlines.


References:


This article is based on real-world experience across several Go projects, both as an individual contributor and as a lead. If you have additional insights or unique experiences, drop them in the discussion section! 🚀

Related Articles

💬 Comments