DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

Dependency Injection in Go, After Years of Symfony

The container I had, the container I don't have, and what I do instead.


👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This one is about the single piece of Symfony I missed most on the way over, and about what I found when I stopped missing it and looked at what I'd built in its place. Notes: github.com/brilliant-almazov.

As always: this is what I do on one codebase, with the costs I actually pay. Not advice for yours.


What the container actually did for me

In Symfony, dependency injection is not a topic. It's infrastructure that has been finished for years, and the measure of how finished it is: I went whole quarters without opening a wiring file.

Six things it does, and it's worth separating what each one buys from what each one hides, because they are not the same list.

Autowiring. A constructor asks for an interface, the container resolves it by type. Adding a dependency to a class is adding a parameter - no configuration line, no registration, nothing else edited.

final class OrderCreator 
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly EventBus $events,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

What it buys: a new collaborator costs one line, in the file where it's used. What it hides: the code that constructs this object doesn't exist as text anyone wrote. There is no call site to read.

Services declared by configuration. config/services.yaml with autowire: true, autoconfigure: true, and a resource glob that registers an entire directory. Buys: a directory of new classes is available without touching a wiring file at all. Hides: the population of the container is data, and the answer to "what objects exist" is a query, not a file.

Decoration. #[AsDecorator], or decorates: plus .inner in configuration, wraps an existing service so that every existing caller gets the wrapper instead - callers untouched, order controlled by priority. Buys: cross-cutting behaviour added without editing a single consumer. Hides: the class you're reading may not be the object that runs. OrderCreator in the file, three decorators in the container, and nothing at the call site says so.

Lazy services. lazy: true gives you a proxy; the real object is constructed on first method call. Buys: an expensive dependency that most requests never touch stops costing those requests anything. Hides: construction time moves to a place you're not looking at when you profile.

Per-environment graphs and compiler passes. A different set of definitions per environment, plus compiler passes that rewrite the graph as it's built. Buys: the test graph and the production graph are the same design with substituted parts. Hides: two graphs that must stay true to each other, and one of them is assembled by code that runs at build time.

A compiled container. The whole graph is resolved ahead of the request and written out as generated PHP. This is the part people forget when they call container DI "dynamic": a missing dependency, an ambiguous type hint, a service that can't be constructed - all of that fails when the container compiles, not on the request that needed it. And debug:container / debug:autowiring answer questions about the graph from the command line.

Put the buys together and you get the single sentence that makes the container worth it: adding a dependency costs one constructor parameter, and the graph is verified before anything serves traffic.

Put the hides together and you get the price: the graph is not in the code you read. To find out what an object actually receives at runtime, you run a tool. That's not a complaint - in a monolith with hundreds of services in one process, hand-writing that assembly would be worse in every direction. The container earns its keep exactly there, and the size of the graph is why.

  in Symfony           what it buys              what it hides
  ---------------------------------------------------------------------
  autowire: true       one constructor param     nobody writes the
                                                 constructing call
  services.yaml        a directory by one glob   what exists is data
  #[AsDecorator]       no consumer edited        what runs is not
                                                 what you read
  lazy: true           built on first call       construction moves
  services_test.yaml   parts swapped per env     two graphs to keep true
  debug:container      the graph in one command  it is not in a file
Enter fullscreen mode Exit fullscreen mode

Six rows, each a Symfony container feature named in code - autowire, services.yaml, AsDecorator, lazy, per-environment definitions, debug:container - with two columns beside each row headed what it buys and what it hides

Why Go doesn't have this out of the box

Go's answer is that a dependency is a parameter, and parameters are passed at call sites, and call sites are text in a file that a person wrote.

There are no annotations to read. There is no convention that the runtime inspects your types and builds things for you. The compiler assembles nothing on your behalf; go build does not have a hook where a framework gets to construct your object graph. This is the same design decision that gives Go no inheritance, no implicit constructors, no exceptions - the reading of a line should not depend on machinery declared elsewhere.

I've stopped treating that as an omission. But it has a price, and the price is not "you type more".

The price is that everything the container did for you is now something you either write or do without. Lazy construction, decoration, per-environment substitution, lifecycle ordering - the language has no opinion about any of it, so each one arrives as a small hand-rolled mechanism, written under deadline, by whoever hit the need first.

I have the receipt for this. When I audited my own Go service for code that already existed in the shared platform library, one of the findings was a scheduler initialised lazily on a sync.Once with captured variables, instead of the platform's initialisation-state store. That's lazy: true, rebuilt by hand, in one service, by me. It works. It's also invisible to anything that could tell me it exists - no declaration, no registry, no way to ask the process what is lazy and what isn't.

The same audit found a service running two different loggers in two different packages. Nothing enforces that a dependency is the dependency when the dependency is just an argument.

The four options, and what each one charges

Everybody in Go knows all four. They're not ranked; they're priced.

Manual assembly at the composition root

The direct route: a function that constructs everything in order and hands the result to the daemon.

func buildServer(ctx context.Context, deps platform.Deps) (*Server, error) {
    orders := repository.NewOrders(deps.Executor)
    entities := repository.NewEntities(deps.Executor)
    orderWrites := manager.NewOrders(orders, deps.Clock, deps.Ids)
    // ...and so on, in dependency order, for every domain
}
Enter fullscreen mode Exit fullscreen mode

Charges: one long function that every feature touches, so every feature branch conflicts in it; the order is maintained by hand; and - this is the one that actually bites - nothing tells you when a node is missing. More on that below, because it's the whole reason this article isn't just "write it by hand, it's fine".

When it fits: a graph you can hold in your head, or a graph that repeats the same shape enough that the repetition can be factored out.

Code generation of the graph

The wire-shaped approach: declare provider functions, declare what you want, and a generator writes the assembly function you'd have written yourself, at build time, in real Go you can read and step through.

Charges: a generator in the build path, and everyone who touches the project needs it; errors phrased in the generator's vocabulary rather than the language's; and when something is wrong you debug generated code, which is a file nobody wrote and everybody is slightly afraid to edit. It also puts a DO NOT EDIT header in the repository - and in my experience a DO NOT EDIT header is a rule, not a mechanism, unless a drift check enforces it.

When it fits: a large, fairly stable graph where the assembly function has become genuinely unpleasant, and the team already runs generators for other reasons.

A runtime container with reflection

The dig/fx-shaped approach, and the closest thing to what I had in Symfony: register constructors, ask for a type, get the graph built by reflection. fx adds lifecycle on top - ordered start and stop hooks, which is a real problem you'd otherwise solve by hand in every service.

Charges: resolution moves to runtime. A missing provider is a start-up error phrased in terms of reflect.Type, not a compile error on a line. The graph isn't in the code - you're back to "ask the tool", except the tool is now inside your process. Debugging goes through the container, which means the layer you least want to learn is the layer you must learn first when something is wrong.

When it fits: many components with the same shape and a real lifecycle problem - things that must start in order and stop in reverse. If you're writing that ordering by hand in every binary, a container is not an indulgence.

Functional options and plain constructors

Not dependency injection at all, but it covers the part of the problem people usually mean: an object with several optional collaborators.

func NewClient(base string, opts ...Option) *Client
Enter fullscreen mode Exit fullscreen mode

Charges: constructors grow, and the option list quietly becomes the object's configuration surface - which is fine until two options are mutually exclusive and nothing says so. Nothing resolves anything: you still write every call site.

When it fits: a library boundary, or any constructor where most callers want defaults and a few want one thing changed.

    option               gives                      charges
    -------------------------------------------------------------------
 >  manual assembly      the graph is a file,       a missing node is
                         in order                   a green build
    generated graph      real Go the compiler       a generator in the
                         checks                     build; code nobody wrote
    runtime container    resolution and             a start-up message
                         lifecycle ordering         about a reflect.Type
    functional options   defaults with one          constructors grow into
                         thing changed              a config surface
Enter fullscreen mode Exit fullscreen mode

Four columns headed manual assembly, code generation, runtime container, functional options; each column lists what it gives and what it charges, with manual assembly marked as the one picked

Two of these - manual assembly and options - are the language's own grain. The other two rebuild a container out of tooling: one at build time, one at run time. That's the actual choice, and it's not between "explicit" and "convenient". It's about when you want to find out that the graph is wrong: at compile, at start-up, or never.

How I assemble a service

My answer is a split, and I only noticed it was a split when I sat down to write this. Infrastructure is declared. Domain objects are written.

The declared half

Every service carries one manifest. It declares the daemons and the resources, and that declaration is the only place they're declared:

service:
  name: <service>
  daemons:
    - name: server
      handlers: [grpc]
    - name: worker
      handlers: [scheduler]

infra:
  postgres:
    - name: main
  tx:
    - name: main
      pool: main
  grpc:
    - name: api
      daemons: [server]
  resources:
    - type: messaging
      name: main
Enter fullscreen mode Exit fullscreen mode

From that declaration the platform builds the pool, the transaction registry, the gRPC server, the scheduler and the broker connection - and the name of every environment variable follows from it by one convention, <TYPE>_<NAME>_<FIELD>:

Declaration Variable
postgres: main POSTGRES_MAIN_DSN
grpc: api GRPC_API_PORT
messaging: main MESSAGING_MAIN_RABBITMQ_URL

That is a container. It's a small one, it only wires infrastructure, and I did not think of it as dependency injection until I compared it side by side with services.yaml. Same trade exactly: a declaration instead of a call site, convenience bought with implicitness.

What makes me comfortable with it is the one thing Symfony's container also does and most hand-rolled configuration doesn't - the declaration is verified. The service's variable catalogue is a generated artefact: 59 variables, 45 of them declared by the platform and 14 by the service's own configuration, each service-side one recorded with the file and line it's defined in. Drift between the declaration and the code fails CI. If the graph isn't in the code I read, then something had better be checking it, and here something is.

  declared - the manifest             written - the assembly
  ---------------------------------   -------------------------------
  postgres: main                      executor    a pool or a tx
    -> POSTGRES_MAIN_DSN              repository  reads only
  grpc: api                           manager     writes, find-or-create
    -> GRPC_API_PORT                  method      one of 20 generic RPCs
  messaging: main                     Calls       the domain's handlers
    -> MESSAGING_MAIN_RABBITMQ_URL    daemon      server or worker
  ---------------------------------
  59 variables catalogued, 45 of
  them from the platform; drift
  against the code fails CI
Enter fullscreen mode Exit fullscreen mode

Two panels: on the left the declared half, showing manifest resources resolving into environment variable names and noting the catalogue check; on the right the written half, showing the assembly order from executor through repository, manager, methods and calls to the daemon

The written half

Domain objects are assembled by hand, in layers, in dependency order, in the daemon's assembly package for the domain:

  1. the executor - a pool or a transaction, and nothing downstream knows which;
  2. repositories - read only;
  3. managers - write only, including find-or-create;
  4. the RPC method bodies, which live in generic methods rather than in the domain;
  5. the domain's Calls, where the handlers are named;
  6. the daemon, which mounts them.

Two rules do most of the work here. Dependencies are interfaces, one interface per file in a cross-cutting port package - so an assembly line reads as "this concrete thing satisfies that named role", and a test substitutes at the same seam. And the transaction is not an argument: tx pgx.Tx in a signature is forbidden. Transactionality is a decorator applied from the outside, on mutations only, with the executor taken from the context. The domain code does not know it is running inside a transaction.

That decorator is worth noticing, because it's Symfony's decoration idea kept and everything around it dropped. It's the same benefit - a cross-cutting concern that consumers don't implement - and the difference is that it's applied at one visible place per domain rather than through a configuration key. I took the pattern and refused the indirection.

Why this stays readable longer than you'd expect

Manual wiring supposedly collapses under its own weight. Mine hasn't yet, and the reason is not discipline. It's that the repeated subtree is a generic, not a copy.

Packages are laid out by component type first with the domain nested inside - internal/repository/<domain>, internal/manager/<domain>, internal/grpc/handler/<domain>, internal/daemon/server/<domain>. Between two domains, the assembly differs in the type and in the spec, and in nothing else. So the assembly itself became a core with type parameters - Builder[Repo, Mgr], Domain[Repo, Mgr], Input, ManagerInput[Repo], HandlerInput[Repo, Mgr] - and a domain's wiring is four small files with the same skeleton.

The numbers say the same thing from outside: the service is 2733 non-generated Go files across 252 packages, averaging 39 lines; 44 files exceed 100 lines and only two of those aren't tests. A composition root would be enormous in that tree if each domain's subtree were bespoke. It isn't, so it isn't.

This is the honest form of "manual wiring is fine": manual wiring is fine when the wiring is regular. A container's biggest win is over irregularity - N different shapes, each needing its own construction. Remove the irregularity and you remove most of the win.

  executor      port.Executor       a pool or a transaction
     |
  repository    Reader[In, Out]     read side only
     |
  manager       Manager             write side only, find-or-create
     |
  method        20 generic RPCs     the RPC body, not in the domain
     |
  calls         Domain[Repo, Mgr]   the domain's handlers, named
     |
  daemon        server / worker     one image, two assembly points

  transactionality: a decorator, applied on mutations only.
  tx pgx.Tx never appears in a signature - the executor comes
  from the context, and the domain does not know it is in one.
Enter fullscreen mode Exit fullscreen mode

Six stacked bands showing the assembly order from executor at the top through repository, manager, generic methods, calls and daemon, with the transaction decorator marked as applying to mutations only

Where manual assembly breaks

Now the part I'd want to read if someone else wrote this.

It fails silently, and that's a category difference

The same audit that found the hand-rolled lazy scheduler found this: a cache-invalidation consumer, fully written, registered in no daemon. Its subscription was never wired anywhere. In production, cache invalidation did not run at all.

Look at what didn't catch it:

  • the compiler - the package compiles perfectly; being uncalled is not an error in any language;
  • the unit tests - green, because they test the consumer's logic, and the logic was correct;
  • review - there is nothing to see. The absence is in a different file from the thing that's absent.

This is the exact failure mode a compiled container removes. In Symfony, a service that nothing uses is at worst a warning you can query for, and a service whose dependency is missing fails the container build. In hand-wired Go, "I forgot to add the line" produces a green build, green tests, and a feature that silently doesn't exist.

That asymmetry is the strongest argument against my own approach, and I'd rather state it than argue with it.

  internal/consumer/<domain>   written, correct, registered in no daemon
        |
        +-- the compiler     [passed]  the package compiles
        +-- its unit tests   [passed]  green - the logic is right
        +-- review           [passed]  the absence is in another file
        |
        v
  in production, cache invalidation did not run at all
Enter fullscreen mode Exit fullscreen mode

One case laid out: a consumer that compiles, passes its unit tests and survives review, registered in no daemon, with the production outcome marked broken and the note that not-called is not an error in any language

Two composition roots drift

One image builds two binaries, server and worker, selected by a build argument. That's two assembly points. Anything both need is written twice, and the two copies diverge in the direction nobody is looking - the same class of drift as the two loggers the audit found in one service.

A container has one graph and a per-binary selection of it. Hand wiring has two graphs that happen to agree today.

The tests are a third graph

Every test that needs a real graph builds one. My answer is rigs and stands in a shared test-support package with no assertions in them - which works, and which is a third assembly point that drifts from the other two exactly the same way. Nothing about hand wiring makes this go away; it makes it your problem to name.

The threshold, said honestly

I don't have a node count where I'd switch. What I have is three signals, and any one of them is the moment:

  • a subtree that isn't regular - a domain whose construction genuinely differs, appearing more than twice;
  • lifecycle ordering by hand - things that must start in order and stop in reverse, written per binary, which is a bug factory and precisely what fx exists for;
  • a wiring omission reaching production twice - because the first time is a mistake and the second time is a property of the method.

What I'd do before switching is climb the ladder I use for everything else: reminder → rule → check. A reminder lives for one conversation. A rule lives while people read it. Only a check works - a structure test that walks the tree and fails the build when a consumer type exists that no daemon's assembly references. That test would have caught the unregistered consumer, it is exactly the compiled container's guarantee reproduced at a fraction of the machinery, and I want to be precise: I haven't written it. It's a plan. The rule that says "always register your consumer" is currently living in my head, which is the level of the ladder that demonstrably didn't work.

  break point              what absorbs it today            status
  -------------------------------------------------------------------
  a wiring omission        a structure test over the tree   NOT WRITTEN
  two composition roots    nothing - server and worker      DRIFTS
                           are assembled twice
  a third graph in tests   rigs and stands, no assertions   held
  an irregular subtree     one generic assembly per domain  held
Enter fullscreen mode Exit fullscreen mode

Four break points of hand wiring as rows - silent omission, two composition roots, a third graph in tests, an irregular subtree - each with what absorbs it today and the one marked as still unwritten

The open question

So: does Go need a proper container, or is the convenience necessarily bought with implicitness?

I notice I haven't answered it consistently in my own code. I accepted declaration-driven wiring for infrastructure - the manifest is a container by any reasonable definition - and refused it for domain objects. That line isn't "explicit always". It's closer to: declare the plumbing that is identical in every service; write the graph that is the design. The manifest describes something that should be the same everywhere and boring; the domain assembly describes decisions specific to this service, and I want those in a file, in order, where a reader trips over them.

The other half of my comfort is the check. The declared half of my wiring is safe not because it's small but because drift between the declaration and the reality fails a build. That suggests the thing I actually want from a Go container isn't reflection and it isn't code generation - it's verification: something that knows what the graph is supposed to be and fails when the code disagrees. Symfony gets that by compiling the container. Wire gets it by generating real code the compiler then checks. A runtime container mostly doesn't get it at all, which is why it's the option I'm least drawn to despite it being the one closest to what I had.

Where that leaves me, for now: hand wiring plus a regular layout plus generics for the repeating subtree, and one unwritten structure test that would close the gap the container closed for free. I'm staying here because the graph being readable has been worth more to me than the graph being short - and because the one production bug this approach cost me was cheap to find once I went looking, which is not a guarantee, just a result.

Ask me again when the third binary shows up.


That's my experience and my price for it. If you've built the check I described, if you run a container in Go and it's been fine, or if you look at this differently - I'd like to hear how it's solved on your side, and what broke when you tried.

Top comments (0)