No matter which language you’re writing your service in, it’s generally a good idea to separate your external dependencies from your business-domain logic. Let’s say your order service needs to make an RPC call to an external payment service like Stripe when a customer places an order.
Usually in Go, people make a package called external
or http
and stash the logic of
communicating with external services there. Then the business logic depends on the
external
package to invoke the RPC call. This is already better than directly making RPC
calls inside your service functions, as that would make these two separate concerns
(business logic and external-service wrangling) tightly coupled. Testing these concerns in
isolation, therefore, would be a lot harder.
While this is a fairly common practice, I was looking for a canonical name for this pattern to talk about it in a less hand-wavy way. Turns out Martin Fowler wrote a blog post on it a few moons ago, and he calls it the Gateway pattern. He explores the philosophy in more detail and gives some examples in JS. However, I thought that Gophers could benefit from a few examples to showcase how it translates to Go. Plus, I wanted to reify the following axiom:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
— Uncle Bob
In this scenario, our business logic in the order
package is the high-level module and
external
is the low-level module, as the latter concerns itself with transport details.
Inside external
, we could communicate with the external dependencies via either HTTP or
gRPC. But that’s an implementation detail and shouldn’t make any difference to the
high-level order
package.
order
will communicate with external
via a common interface. This is how we satisfy the
“both should depend on abstractions” part of the ethos.
Our app layout looks like this:
yourapp/
├── cmd/
│ └── main.go
├── order/
│ ├── service.go
│ └── service_test.go
├── external/
│ └── stripe/
│ ├── gateway.go
│ ├── mock_gateway.go
│ └── gateway_test.go
└── go.mod / go.sum
Let’s walk through the flow from the bottom up. Think about walking back from the edge to the core, as in Alistair Cockburn’s Hexagonal Architecture lingo where edge represents the transport logic and core implies the business concerns.
The Stripe implementation lives in external/stripe/gateway.go
. For simplicity’s sake,
we’re pretending to call the Stripe API over HTTP, but this could be a gRPC call to another
service.
// external/stripe/gateway.go
package stripe
import "fmt"
type StripeGateway struct {
APIKey string
}
func NewStripeGateway(apiKey string) *StripeGateway {
return &StripeGateway{APIKey: apiKey}
}
// Handle all the details of making HTTP calls to the Stripe service here.
func (s *StripeGateway) Charge(
amount int64, currency string, cardToken string) (string, error) {
fmt.Printf(
"[Stripe] Charging %d %s to card %s\n",
amount, currency, cardToken,
)
return "txn_live_123", nil
}
// Make another HTTP call to the Stripe service to perform a refund.
func (s *StripeGateway) Refund(transactionID string) error {
fmt.Printf("[Stripe] Refunding transaction %s\n", transactionID)
return nil
}
Notice that the stripe
package handles the details of communicating with the Stripe
endpoint, but it doesn’t export any interface for the higher-level module to use. This is
intentional.
In Go, the general advice is that the consumer should define the interface they want, not the provider.
Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.
— Go code review comments
That gives the consumer full control over what it wants to depend on, and nothing more. You don’t accidentally couple your code to a bloated interface just because the implementation provided one. You define exactly the shape you need and mock that in your tests.
So, in the order
package, we define a tiny private interface that reflects the use case.
// order/service.go
package order
type paymentGateway interface {
Charge(amount int64, currency, cardToken string) (string, error)
}
type Service struct {
gateway paymentGateway
}
// Pass the Stripe implementation of paymentGateway at runtime here.
func NewService(gateway paymentGateway) *Service {
return &Service{gateway: gateway}
}
// In production code, this calls the .Charge method of the Stripe implementation,
// but during tests, this will call .Charge on a mock gateway.
func (s *Service) Checkout(amount int64, cardToken string) error {
_, err := s.gateway.Charge(amount, "USD", cardToken)
return err
}
The order service doesn’t know or care which implementation of the gateway it’s using to
perform some action. It just knows it can call Charge
on the provided gateway type. It
doesn’t need to care about the Refund
method on the Stripe gateway implementation. Also,
the paymentGateway
interface is bound to the order
package, so we’re not polluting the
API surface with a bunch of tiny interfaces.
Now, when testing the service logic, you just need to write a tiny mock implementation of
paymentGateway
and pass it to order.Service
. You don’t need to reach into the
external/stripe
package or wire up anything complicated. You can place the fake right next
to your service test. Since interface implementations in Go are implicitly satisfied,
everything just works without much fuss.
// order/service_test.go
package order_test
import (
"testing"
"yourapp/order"
)
type mockGateway struct {
calledAmount int64
calledToken string
}
func (m *mockGateway) Charge(
amount int64, currency, cardToken string) (string, error) {
m.calledAmount = amount
m.calledToken = cardToken
return "txn_mock", nil
}
func TestCheckoutCallsCharge(t *testing.T) {
mock := &mockGateway{}
svc := order.NewService(mock)
err := svc.Checkout(1000, "tok_test_abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if mock.calledAmount != 1000 {
t.Errorf("expected amount 1000, got %d", mock.calledAmount)
}
if mock.calledToken != "tok_test_abc" {
t.Errorf("expected token tok_test_abc, got %s", mock.calledToken)
}
}
The test is focused only on what matters: Does the service call Charge
with the correct
arguments? We’re not testing Stripe here. That’s its own concern.
You can still write tests for the Stripe client if you want. You’d do that in
external/stripe/gateway_test.go
.
// external/stripe/gateway_test.go
package stripe_test
import (
"testing"
"yourapp/external/stripe"
)
func TestStripeGateway_Charge(t *testing.T) {
gw := stripe.NewStripeGateway("dummy-key")
txn, err := gw.Charge(1000, "USD", "tok_abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if txn == "" {
t.Fatal("expected transaction ID, got empty string")
}
}
Finally, everything is wired together in cmd/main.go
.
// cmd/main.go
package main
import (
"yourapp/external/stripe"
"yourapp/order"
)
func main() {
stripeGw := stripe.NewStripeGateway("live-api-key")
// Passing the real Stripe gateway to the order service.
orderSvc := order.NewService(stripeGw)
_ = orderSvc.Checkout(5000, "tok_live_card_xyz")
}
It’s also common to call gateways “client.” Some people prefer that name. However, I think client is way overloaded, which makes it hard to discuss the pattern clearly. There’s the HTTP client, the gRPC client, and then your own client that wraps these. It gets confusing fast. I prefer “gateway,” as Martin Fowler used in his original text.
In the Go context, the core idea is that a service function uses a locally defined gateway interface to communicate with external gateway providers. This way, the service and the external providers are unaware of each other’s existence and can be tested independently.
Recent posts
- Flags for discoverable test config in Go
- You probably don't need a DI framework
- Preventing accidental struct copies in Go
- Go 1.24's "tool" directive
- Capturing console output in Go tests
- Deferred teardown closure in Go testing
- Three flavors of sorting Go slices
- Nil comparisons and Go interface
- Stacked middleware vs embedded delegation in Go
- Why does Go's io.Reader have such a weird signature?