There's a particular kind of fatigue that hits you around the third microservice you've written from scratch. You've copy-pasted the same logger setup, the same health check endpoint, the same middleware boilerplate — and somewhere between wiring up Prometheus metrics and configuring your Zipkin tracer again, you start wondering if there's a better way.
That frustration is exactly what led me to GoFr. And eventually, to contributing to it.
What Even Is GoFr?
GoFr is an opinionated Go framework built specifically for microservices. The keyword here is opinionated — and that's not a criticism, it's the whole point.
Most Go developers have been burned by "batteries not included" frameworks that leave you assembling 12 different libraries and writing glue code for days. GoFr takes the opposite approach. You get structured logging, distributed tracing with OpenTelemetry, database connection pooling, health checks, and Pub/Sub support out of the box. The framework makes decisions for you so you can focus on your actual business logic.
Here's what a basic GoFr service looks like:
package main
import "gofr.dev/pkg/gofr"
func main() {
app := gofr.New()
app.GET("/hello", func(ctx *gofr.Context) (interface{}, error) {
return "Hello, World!", nil
})
app.Run()
}
That's it. Behind that gofr.New() call, you've already got logs shipping in a structured format, traces being collected, and a /health endpoint ready to respond. Compare that to what you'd normally wire up manually, and the value proposition is immediately obvious.
GoFr is also part of the CNCF landscape — which tells you the project is serious, has community backing, and isn't going anywhere.
Why I Started Looking at It
Honestly? I stumbled onto it through a random GitHub trending notification. I was deep in building a side project — a small order-processing service — and I'd just spent an embarrassing amount of time debugging a tracing context that wasn't propagating correctly through my middleware chain. I opened GitHub, saw GoFr trending, skimmed the README, and thought: wait, this just... handles that?
I spent a weekend playing with it. Got a basic service running in maybe 20 minutes. The context propagation I'd been fighting with for two days? Worked out of the box.
But what kept me around wasn't just the features. It was watching the maintainers respond to issues. Quick, thoughtful, actually helpful. The codebase was readable — not some labyrinthine monstrosity where you need a map and a flashlight to find anything. And the issue tracker had good good first issue labels with enough context that you could actually understand what was needed without writing a novel asking for clarification.
That's when I thought: I should probably give something back here.
Setting Up Locally (And Where I Immediately Got Stuck)
The first step was forking and cloning:
git clone https://github.com/YOUR_USERNAME/gofr.git
cd gofr
git remote add upstream https://github.com/gofr-dev/gofr.git
Getting Go dependencies was straightforward. The part that tripped me up was the test infrastructure. GoFr's integration tests talk to real databases — MySQL, Redis, Postgres, Kafka, etc. The CONTRIBUTING.md lists all the Docker commands you need, and there are a lot of them:
docker run --name gofr-mysql -e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=test -p 2001:3306 -d mysql:8.0.30
docker run --name gofr-redis -p 2002:6379 -d redis:7.0.5
docker run --name gofr-pgsql -d \
-e POSTGRES_DB=customers \
-e POSTGRES_PASSWORD=root123 \
-p 2006:5432 postgres:15.1
My first mistake: I skipped a few of these thinking "I won't need Cassandra for what I'm working on." Then the test suite failed in a way that took me a while to trace back to a missing container. Lesson learned — just spin them all up.
The other thing I had to configure was golangci-lint and goimports. GoFr's CI pipeline enforces code formatting strictly. Any PR with unformatted code will fail immediately. Set these up in your editor before you write a single line:
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
I use VS Code with the Go extension, and adding these as format-on-save tools saved me from several embarrassing CI failures.
Understanding the Codebase
GoFr's directory structure is reasonably clean once you know what you're looking at:
gofr/
├── pkg/
│ ├── gofr/ # Core framework — app, context, router
│ ├── datastore/ # DB drivers — MySQL, Redis, Mongo, etc.
│ └── service/ # HTTP client utilities
├── examples/ # Example services (great for learning)
└── docs/ # Documentation source
I started in examples/ — reading working examples before touching any production code is a habit I'd recommend to anyone. Then I moved to pkg/gofr/context.go to understand how the *gofr.Context flows through a request, since that's the thing you interact with in every handler.
The thing that stood out: the codebase uses interfaces everywhere. This makes it very testable, but it also means you spend time tracing which concrete type is actually being used at runtime. The first time I saw the datastore interfaces I was a bit confused about what was a contract versus an implementation. Taking the time to map this out mentally (or on paper) before diving into any specific area is time well spent.
How I Found Something to Work On
I didn't go looking for a dramatic bug. I just read through open issues for about an hour. There was one that caught my eye — a documentation inconsistency where the error handling guide showed one pattern in the README example but a slightly different one in the actual example service. Small thing, but the kind of thing that genuinely confuses new users.
There was also a code issue I found while reading through the HTTP service utilities: the error returned when a request context was cancelled wasn't being wrapped consistently. In some paths you'd get the raw context.Canceled error; in others, it was wrapped with some additional info. Not a crash, just an inconsistency that made error handling upstream harder than it needed to be.
That became my first real contribution target.
What I Actually Contributed
The Fix
The issue was in the HTTP service client — when a context got cancelled mid-request, the error handling was inconsistent. Here's a simplified version of what I found:
Before:
func (h *httpService) Get(ctx context.Context, path string, params map[string]interface{}) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url+path, nil)
if err != nil {
return nil, err
}
resp, err := h.client.Do(req)
if err != nil {
// Problem: just returning raw error, no context about what failed
return nil, err
}
return resp, nil
}
When ctx was cancelled, err would be something like Get "http://...": context canceled — okay, but the calling code had no structured way to detect specifically that the context was cancelled versus a network timeout versus a DNS failure.
After:
func (h *httpService) Get(ctx context.Context, path string, params map[string]interface{}) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url+path, nil)
if err != nil {
return nil, err
}
resp, err := h.client.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, fmt.Errorf("request cancelled for path %s: %w", path, context.Canceled)
}
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("request timed out for path %s: %w", path, context.DeadlineExceeded)
}
return nil, err
}
return resp, nil
}
Now callers can use errors.Is(err, context.Canceled) reliably. The error wrapping preserves the sentinel so you don't lose the ability to check it upstream, but it adds just enough context to know which request cancelled.
Tests
Every change needs test coverage — GoFr's CI will fail if coverage drops. I added table-driven tests:
func TestHTTPService_Get_ContextCancelled(t *testing.T) {
tests := []struct {
name string
cancelCtx bool
expectedErr error
}{
{
name: "context cancelled mid-request",
cancelCtx: true,
expectedErr: context.Canceled,
},
{
name: "normal request",
cancelCtx: false,
expectedErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if tt.cancelCtx {
cancel()
} else {
defer cancel()
}
// ... test body
_, err := svc.Get(ctx, "/test", nil)
if tt.expectedErr != nil {
assert.True(t, errors.Is(err, tt.expectedErr))
} else {
assert.NoError(t, err)
}
})
}
}
How to Contribute: The Actual Steps
1. Fork and clone:
# Fork on GitHub first, then:
git clone https://github.com/YOUR_USERNAME/gofr.git
cd gofr
git remote add upstream https://github.com/gofr-dev/gofr.git
2. Set up your tooling:
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
3. Spin up the test infrastructure:
Refer to CONTRIBUTING.md for the full Docker command list. Don't skip any.
4. Find an issue:
Go to github.com/gofr-dev/gofr/issues and filter by good first issue. Read through a few even if you don't take them — it'll give you a feel for the types of contributions that are welcome.
5. Comment before you code:
This is important. Before you start writing anything, comment on the issue and ask to be assigned. The maintainers move fast — I've seen two people open nearly identical PRs because neither commented first. Save yourself the frustration.
6. Branch off development:
git checkout development
git pull upstream development
git checkout -b fix/your-descriptive-branch-name
7. Write code + write tests. Both are required.
Run tests before opening your PR:
go test ./... -count=1
golangci-lint run
8. Submit PR to development branch (not main):
Keep your PR description focused. What problem does it solve? What did you change? Link the issue. Don't write an essay.
Mistakes I Made (So You Don't Have To)
Skipping the issue assignment step. I opened a PR without getting assigned first. Another contributor had the same fix half-done. My PR got closed. Just comment first — it takes 30 seconds.
Not running the linter locally. I pushed my first draft without running golangci-lint. CI failed on a simple unused variable. Embarrassing and entirely avoidable.
Going too big too fast. My second impulse was to tackle a large refactor I'd spotted. I resisted, started with something small, got it merged, and then raised a discussion about the larger thing. Building trust incrementally works.
Not looking at similar existing code. GoFr has patterns it follows consistently — how it wraps errors, how it names things, how tests are structured. I wasted time writing a solution that was technically correct but stylistically inconsistent with the rest of the codebase. Read existing code before writing new code.
Tips for First-Time Contributors
Don't be intimidated by the codebase. You don't need to understand all of GoFr before contributing. Pick one area, read just that area deeply, and ignore the rest.
Start with documentation. If you're nervous about code, a doc fix is a perfectly legitimate first PR. Fix a typo, clarify a confusing example, add a missing step to a setup guide. It's not glamorous but it's real work and it gets you familiar with the contribution flow without the pressure of getting code right.
Use GitHub Discussions. If you have an idea but aren't sure if it's a good fit, post in the discussions tab before opening an issue. The maintainers are genuinely helpful there.
Read closed PRs. This is underrated. Looking at PRs that were rejected or revised tells you a lot about what the maintainers care about — code style, test coverage expectations, the level of detail they want in descriptions.
Communicate early, communicate often. If you're stuck, say so in the issue thread. If your approach changed, mention it. Maintainers don't expect perfection, they expect communication.
The Part Nobody Mentions: What You Actually Get Out of This
The obvious answer is the GoFr swag — the project does send out merchandise when your PR gets merged, which is a nice touch.
But honestly? What I actually walked away with was a much better understanding of how production-grade Go is written. Reading GoFr's source — the way it handles context propagation, the interface design, the test organization — taught me things I couldn't have gotten from tutorials. You see real decisions made for real reasons.
And there's something quietly satisfying about finding a real project on your go.mod and knowing you changed one small thing that makes it marginally better. However small the contribution, it's not hypothetical. Developers you'll never meet are running code that has your fingerprints on it.
That's worth a lot more than swag.
Resources
If this helped you get started, drop a star on the repo. And when your first PR gets merged — welcome to the list.
Top comments (0)