- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Container withdrawal hits on day two of a fresh Go service. Your hand types app( and the editor offers nothing back. A quick search for composer require laravel/container returns the wrong ecosystem. The Go docs have no bind() function. The framework you trusted to wire your classes is gone.
This is the second-most common PHP-to-Go pain. The first is goroutines. The second is the moment your hand reaches for the container and finds an empty room.
Here is the thing nobody tells you. The container is not missing. The whole concept got moved into a single function called main. Go did not lose dependency injection. It removed the magic and left you with the mechanics.
Once you stop looking for the container, the language opens up. Boot is faster. The dependency graph fits in your head. Tests stop reaching for mocking helpers. You read one file and know how the program is built.
google/wire and uber-go/fx exist. Neither is the answer most services need.
What the Laravel container actually does
When you write a Laravel controller like this:
<?php
namespace App\Http\Controllers;
use App\Services\OrderService;
class OrderController extends Controller
{
public function __construct(
private OrderService $orders,
) {}
public function store(Request $request)
{
$order = $this->orders->place($request->all());
return response()->json($order, 201);
}
}
You never call new OrderController(). You never call new OrderService(). Laravel reads the constructor type-hints, reflects on them, walks the graph, and hands you a fully-wired instance. The framework calls this zero configuration resolution: if a class type-hints concrete classes the container can build, no registration is needed. Interfaces get bound in a service provider.
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(OrderService::class, function ($app) {
return new OrderService(
$app->make(PaymentGateway::class),
$app->make(OrderRepository::class),
config('orders.fee_cents'),
);
});
}
}
You can also resolve manually with app()->make(OrderService::class) or the app(OrderService::class) helper. Both reach into the same global container and pull a constructed graph.
This works because PHP processes the request, builds the graph, serves the response, and dies. Under traditional FPM the container is rebuilt on every request, and the reflection cost is amortized across one HTTP cycle then thrown away. (Laravel Octane on Swoole or RoadRunner keeps the worker alive and reuses the container across requests, which changes that math but not the mental model below.) Laravel hides it well enough that most controllers feel like they have no constructor at all.
Why Go skipped the container
Go is not a request-per-process language. A Go binary boots once and stays up for weeks. There is no per-request reflection budget. There is one process, one dependency graph, and one place where the graph is constructed.
That place is main.
package main
import (
"context"
"database/sql"
"log/slog"
"net"
"net/http"
"os"
_ "github.com/jackc/pgx/v5/stdlib"
"example.com/orders/internal/adapter/http/handler"
"example.com/orders/internal/adapter/postgres"
"example.com/orders/internal/adapter/stripe"
"example.com/orders/internal/domain/order"
)
func main() {
ctx := context.Background()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
logger.Error("open db", "err", err)
os.Exit(1)
}
defer db.Close()
Now wire the adapters, the domain service, and the HTTP handler in dependency order:
// Adapters satisfy domain ports.
repo := postgres.NewOrderRepository(db)
payments := stripe.NewGateway(os.Getenv("STRIPE_KEY"))
svc := order.NewService(repo, payments, logger)
h := handler.NewOrderHandler(svc, logger)
mux := http.NewServeMux()
mux.Handle("POST /orders", h.Create())
srv := &http.Server{
Addr: ":8080",
Handler: mux,
BaseContext: func(_ net.Listener) context.Context { return ctx },
}
logger.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
logger.Error("serve", "err", err)
os.Exit(1)
}
}
That is the entire DI story for a small service. Constructors return structs. main calls them in dependency order. The compiler refuses to build if you wired something wrong.
Look at what you got for free:
- No reflection at boot. The binary jumps straight into
ListenAndServe. - No service provider files. No
register()methods. No tagged container singletons. - No global
app()helper to leak into a unit test. - The graph is auditable in one screen of code.
You also got something the container hides: exposure. When the wiring is in your face, you notice that OrderService now takes seven dependencies and probably wants splitting. Laravel happily injects all seven and you never see the smell.
The constructor pattern that replaces bind
In Go, an interface lives next to the code that uses it, not next to the code that implements it. The domain owns the port. The adapter implements it. main is the only file that knows about both.
// internal/domain/order/service.go
package order
import (
"context"
"log/slog"
)
type Repository interface {
Save(ctx context.Context, o Order) error
FindByID(ctx context.Context, id string) (Order, error)
}
type PaymentGateway interface {
Charge(ctx context.Context, amountCents int64, token string) (string, error)
}
type Service struct {
repo Repository
payments PaymentGateway
logger *slog.Logger
}
func NewService(repo Repository, p PaymentGateway, l *slog.Logger) *Service {
return &Service{repo: repo, payments: p, logger: l}
}
The service consumes its ports through Place:
func (s *Service) Place(ctx context.Context, in PlaceInput) (Order, error) {
chargeID, err := s.payments.Charge(ctx, in.AmountCents, in.Token)
if err != nil {
return Order{}, err
}
o := Order{ID: in.ID, ChargeID: chargeID, AmountCents: in.AmountCents}
if err := s.repo.Save(ctx, o); err != nil {
return Order{}, err
}
return o, nil
}
// internal/adapter/postgres/order_repository.go
package postgres
import (
"context"
"database/sql"
"example.com/orders/internal/domain/order"
)
type OrderRepository struct{ db *sql.DB }
func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) Save(ctx context.Context, o order.Order) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO orders (id, charge_id, amount_cents) VALUES ($1, $2, $3)`,
o.ID, o.ChargeID, o.AmountCents,
)
return err
}
func (r *OrderRepository) FindByID(ctx context.Context, id string) (order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx,
`SELECT id, charge_id, amount_cents FROM orders WHERE id = $1`, id,
).Scan(&o.ID, &o.ChargeID, &o.AmountCents)
return o, err
}
postgres.OrderRepository does not declare anywhere that it implements order.Repository. Go interfaces are satisfied structurally. As long as the methods match, the compiler accepts it when you pass it to order.NewService. If they do not match, the build fails at NewService(repo, ...) with a precise error pointing at the missing method.
This is the equivalent of Laravel's bind(PaymentGateway::class, StripeGateway::class). Except the binding lives in main, runs at compile time, and has zero runtime cost.
The test seam comparison
Here is where the container tax shows up clearly. In Laravel, replacing a binding for a test usually involves the container itself:
<?php
namespace Tests\Unit;
use App\Contracts\PaymentGateway;
use App\Services\OrderService;
use Tests\TestCase;
use Mockery;
class OrderServiceTest extends TestCase
{
public function test_places_order_when_payment_succeeds(): void
{
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once()
->andReturn('ch_123');
$this->app->instance(PaymentGateway::class, $gateway);
$orders = $this->app->make(OrderService::class);
$order = $orders->place(/* ... */);
$this->assertSame('ch_123', $order->charge_id);
}
}
You bootstrap the framework. You rebind the container. You ask the container for the service. The test depends on the framework being booted.
The Go version asks the container for nothing because there is no container:
package order_test
import (
"context"
"log/slog"
"io"
"testing"
"example.com/orders/internal/domain/order"
)
type stubGateway struct{ id string }
func (s stubGateway) Charge(_ context.Context, _ int64, _ string) (string, error) {
return s.id, nil
}
type stubRepo struct{ saved order.Order }
func (s *stubRepo) Save(_ context.Context, o order.Order) error {
s.saved = o
return nil
}
func (s *stubRepo) FindByID(_ context.Context, id string) (order.Order, error) {
return s.saved, nil
}
Two short stubs replace the mocking library. The test itself just builds the service and calls Place:
func TestPlaceOrderChargesAndSaves(t *testing.T) {
repo := &stubRepo{}
gw := stubGateway{id: "ch_123"}
svc := order.NewService(repo, gw, slog.New(slog.NewTextHandler(io.Discard, nil)))
o, err := svc.Place(context.Background(), order.PlaceInput{
ID: "o_1", AmountCents: 1999, Token: "tok_visa",
})
if err != nil {
t.Fatal(err)
}
if o.ChargeID != "ch_123" {
t.Fatalf("got %q, want ch_123", o.ChargeID)
}
}
No framework boot. No container rebind. No mocking library required: a five-line struct does the job. The test runs in microseconds because there is no dependency tree to construct beyond the three things it actually needs.
This is the part that tends to convert PHP devs. The container was helping you fake things. Once it is gone, the tests get faster and the seams get smaller, because the dependencies were always supposed to be passed in. The container just hid the act.
When the wiring gets bigger
Reasonable concern. "Sure, three deps fit in main. What about thirty?"
The answer most Go services land on is: keep it explicit until it hurts, then group it. A 200-line main.go is not a problem. A 600-line main.go is a sign that you should pull the wiring into helper constructors that group related adapters:
type Adapters struct {
Repo order.Repository
Payments order.PaymentGateway
Mailer notifications.Mailer
}
func buildAdapters(cfg Config, db *sql.DB) Adapters {
return Adapters{
Repo: postgres.NewOrderRepository(db),
Payments: stripe.NewGateway(cfg.StripeKey),
Mailer: ses.NewMailer(cfg.AWSRegion),
}
}
Then main calls buildAdapters, buildServices, buildHandlers. Three function calls, all explicit, all compile-checked, no reflection. This is the pattern you find in most large Go services in production today.
You hold the line at manual constructors for as long as you can, because the moment you reach for a framework, you re-import the things Go was protecting you from.
What about google/wire and uber-go/fx?
These are the two names you will hear when you ask "but isn't there a real DI framework for Go?" There are. They solve different problems and most services do not need either.
google/wire is a code generator. You declare provider sets, run wire once, and it writes the main.go-style wiring code for you at compile time. The generated code is plain Go: nothing wire-specific runs at runtime, and the binary has no reflection cost. The Go team's announcement post describes it as "compile-time dependency injection" (go.dev/blog/wire).
It is useful when your wiring file gets long enough that a typo in one constructor's argument list costs you fifteen minutes of compile-error-chasing. The tradeoff is one more build step and one more thing to teach a new hire.
uber-go/fx is the closest thing Go has to Laravel's container. It uses reflection at runtime to build the graph, supports lifecycle hooks (OnStart, OnStop), and bundles a dependency-graph-aware logger. Wire does at compile time what Fx does at boot, so Fx pays a startup cost Wire does not. As one Leapcell measurement reports, the gap on resolution time alone runs to several orders of magnitude in Wire's favor (Leapcell on Wire vs Fx).
That sounds worse than it is. Go binaries boot fast enough that an extra reflection pass at startup is a one-time cost a long-running service rarely notices.
The honest call:
-
Default: write
main.goby hand. It scales further than you think. -
Reach for
wirewhen the wiring file becomes tedious and you want a code-gen safety net. Output is still plain Go. -
Reach for
fxwhen you need lifecycle management across many components: a service with twenty subsystems that each need ordered start/stop, health checks, and shared shutdown hooks. Uber built it for exactly that case.
Both tools are extensions of the same idea, not replacements for it. Neither makes the dependency graph go away. They write parts of it for you.
What container withdrawal actually feels like
The first week, you miss it. You catch yourself typing app( and remembering it does not exist. You write three lines of constructor calls where Laravel had zero. It feels like a regression.
By the second week, the container's cost finally surfaces. It was never CPU. It was cognitive load and the slow accretion of magic nobody owns. You read someone else's Go service and trace every dependency in five minutes because there is no provider, no facade, no service-locator-disguised-as-helper to chase.
After that, you stop looking for the container and start writing constructors with intent. You notice when a struct has too many dependencies, because you can see all of them lined up in main. You delete the ones that should not be there.
PHP buried the wiring under a framework. Go put it back where it belongs.
The smallest commitment you can make today
If you are mid-migration and want to feel the shift without committing to a full rewrite:
- Pick the smallest service you have. One handler, one repository, one external call.
- Write
main.gofrom scratch. No framework. No DI library. - Define the ports as interfaces in the package that uses them, not the package that implements them.
- Wire the constructors top-down in
main. - Write one test for the service using a five-line stub instead of a mocking library.
That is the entire pattern. Once it clicks for one service, the next ten feel obvious. The container stays in PHP, where it is genuinely useful given how PHP processes work, and Go stays in the place it has always been: honest about what your program is actually doing on boot.
You do not need a service container. You need a main.go you trust.
If this matched how you think (explicit wiring, ports defined where they are used, tests that do not boot a framework), the architectural side is the second book in the series.

Top comments (0)