One way I easily comprehend new concepts is by finding what I already understand that mirrors the new subject and continuously conducting comparative studies. This is the approach I've adopted throughout my Go journey, and dependency injection was one of the first topics that revealed just how differently two languages can solve the same problem.
When I first saw a Go application's main() function with 30 lines of manual dependency wiring, my C# brain screamed: "Where's the DI container?" Further research revealed that Go doesn't need one. The compiler itself is the safety net. Let me explain.
What Is Dependency Injection?
Dependency Injection (DI) is a design pattern where an object receives its dependencies from the outside rather than creating them itself.
The following snippets depict how Go and C# would handle initialization and utilization of services without DI:
1// Go2type UserService struct {}34func (s *UserService) CreateUser(name string) error {5 // ❌ Creates its own dependency - tightly coupled6 repo := NewUserRepository("postgres://localhost:5432/mydb")7 return repo.Save(name)8}1// C#2public class UserService3{4 public void CreateUser(string name)5 {6 // ❌ Creates its own dependency - tightly coupled7 var repo = new UserRepository("postgres://localhost:5432/mydb");8 repo.Save(name);9 }10}The codes above work. They compile and run. They would even save users to the database. But adopting this design pattern is a bad practice because of the following reasons:
- It violates the Single Responsibility Principle.
- They are tightly coupled. Hence, you can't swap implementations.
- You cannot test them.
- The connection string is not configurable. What happens when you want to move to production?
This is why Dependency Injection exists. It solves all four problems by moving the responsibility of creating dependencies to the outside. The below snippets depict how both implementations would look like with DI.
1// Go2type UserService struct {3 repo UserRepository4}56func NewUserService(repo UserRepository) *UserService {7 // ✅ Dependency is injected from outside8 return &UserService{repo: repo}9}1// C#2public class UserService3{4 private readonly IUserRepository _repo;56 // ✅ Dependency is injected from outside7 public UserService(IUserRepository repo)8 {9 _repo = repo;10 }11}How C# Approaches DI
C#, specifically ASP.NET Core, has a built-in DI container that manages object creation and lifetimes automatically.
Registration
1// Program.cs file - The application entry point2var builder = WebApplication.CreateBuilder(args);34// Register services with specific lifetimes5builder.Services.AddSingleton<IEmailService, SendGridEmailService>();6builder.Services.AddScoped<IUserService, UserService>();7builder.Services.AddScoped<IUserRepository, UserRepository>();8builder.Services.AddTransient<IPasswordHasher, BCryptPasswordHasher>();910var app = builder.Build();11app.MapControllers();12app.Run();Resolution (Automatic)
1[ApiController]2[Route("api/users")]3public class UserController : ControllerBase4{5 private readonly IUserService _userService;67 // The DI container automatically resolves IUserService8 // and all of ITS dependencies too9 public UserController(IUserService userService)10 {11 _userService = userService;12 }1314 [HttpPost]15 public IActionResult CreateUser(CreateUserRequest req)16 {17 _userService.CreateUser(req);18 return Ok();19 }20}If you observed closely, you will see that I used three (3) different service registration methods to register the four services. Here is a brief explanation of each method:
`.AddSingleton`: This creates just one instance of the registered service (EmailService in this case) for the entire application. The lifetime of the instance of EmailService created is the lifetime of the application. This means that so long as the application keeps running after startup, it will never create another instance of EmailService. Every part of the application will share the only and same instance of EmailService created. This is ideal for stateless, thread-safe services.
`.AddScoped`: This creates one instance of the registered service (UserRepository in this case) per scope (Think of a scope as every HTTP/s request made via an API). When a request comes in, the services container creates a fresh instance of the UserRepository. Every service within that same request shares this instance. When the request ends, the instance is disposed. A different request gets a different instance. This is ideal for database contexts or anything that needs per-request isolation. The same is applicable to UserService.
`.AddTransient`: This creates a new instance of the registered service (BCryptPasswordHasher in this case) every single time it is requested. Even within the same HTTP/s request, if two services both depend on the same transient service, each gets its own separate instance. This is ideal for lightweight, stateful objects like validators or password hashers that accumulate state and can't be safely reused.
How Go Approaches DI
Go takes a radically different approach: no container, no framework. You wire everything by hand. For me, this is beautiful as it lets you truly understand how your application is put together. There's no "under the hood" pipeline, no runtime surprises, just functions calling functions. At first, everything looked like .AddSingleton to me, but there is more. However, for the most part, "singleton" is the default and most common pattern in Go.
In C#, you'd register your services in Program.cs. In Go, main() serves the same purpose, it's your composition root where every dependency is wired by hand:
1func main() {2 // Load config3 cfg, err := config.Load()4 if err != nil {5 log.Fatalf("load config: %v", err)6 }78 // Open database9 db, err := sql.Open("postgres", cfg.DatabaseDSN)10 if err != nil {11 log.Fatalf("open database: %v", err)12 }13 defer db.Close()1415 // Wire dependencies manually - this IS your DI container16 userRepo := repository.NewUserRepository(db)17 emailService := email.NewEmailService(cfg.SendGridAPIKey, cfg.FromEmail)18 passwordHasher := auth.NewBCryptHasher()19 userService := services.NewUserService(userRepo, emailService, passwordHasher)20 userController := controller.NewUserController(userService)2122 // Set up routes23 mux := http.NewServeMux()24 mux.HandleFunc("POST /api/users", userController.CreateUser)2526 log.Println("server listening on :8080")27 http.ListenAndServe(":8080", mux)28}What you see above is, for the most part, how DI is done in Go. In main(), you pass concrete implementations directly, but the receiving functions typically accept interface types. The difference from C# is that you don't explicitly declare that a struct implements an interface. In Go, interface implementation is implicit. A struct satisfies an interface simply by having the right methods, no explicit declaration.
Implementing Service Lifetimes in Go
Since Go has no DI container, you control lifetimes by where and when you create objects. Going by service lifetimes in C#, how would we implement lifetime in Go?
Singleton lifetime: If you want to implement a service as a singleton, to have a lifetime as the lifetime of the application, you simply have to create your services in the main package (main.go file) just as we did above. This is the default and most common pattern in Go. It works because services are typically stateless, they just hold reference and Go's HTTP server handles each request in its own goroutine.
Scoped lifetime: If you would want to achieve a design that mirrors scoped lifetime in C#, for per-request isolation, especially database transactions, you create scoped dependencies directly in the handler. This means the required services are instantiated fresh for every request. For example, in a money transfer feature, debiting one account and crediting another must succeed or fail together. A scoped transaction ensures this isolation per request:
1// Handler that creates a scoped transaction per request2func (c *TransferController) Transfer(w http.ResponseWriter, r *http.Request) {3 var req TransferRequest4 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {5 http.Error(w, "invalid request", 400)6 return7 }89 // Scoped - new transaction per request10 tx, err := c.db.BeginTx(r.Context(), nil)11 if err != nil {12 http.Error(w, "transaction failed", 500)13 return14 }15 defer tx.Rollback() // Safety net - always rollback unless committed1617 // Scoped repositories using this request's transaction18 accountRepo := repository.NewAccountRepository(tx)19 transferRepo := repository.NewTransferRepository(tx)2021 err = transferRepo.Execute(r.Context(), req)22 if err != nil {23 http.Error(w, err.Error(), 400)24 return // deferred Rollback runs automatically25 }2627 tx.Commit()28 w.WriteHeader(http.StatusOK)29}Transient lifetime: If you would want to achieve, in Go, a design that mirrors lifetime of services registered as transient in ASP.NET, you simply create a new instance wherever and whenever you need one.
1func (s *UserService) CreateUser(ctx context.Context, req CreateUserRequest) error {2 // Transient - new validator each time (created here, not injected)3 v := validation.NewValidator()4 v.Check(req.Name != "", "name is required")5 v.Check(req.Email != "", "email is required")6 v.Check(len(req.Password) >= 8, "password must be at least 8 characters")7 if err := v.Error(); err != nil {8 return err9 }1011 // s.hasher is a singleton, but generates a unique salt per call internally12 hash, err := s.hasher.Hash(req.Password)13 if err != nil {14 return err15 }1617 user := User{18 ID: uuid.New().String(),19 Name: req.Name,20 Email: req.Email,21 Password: hash,22 }2324 if err := s.repo.Save(ctx, user); err != nil {25 return err26 }2728 // Transient - new email message struct each time29 return s.email.Send(EmailMessage{30 To: user.Email,31 Subject: "Welcome!",32 Body: "Hello " + user.Name,33 })34}Advantages of Each Approach
C# Advantages
- Less boilerplate: The container wires everything automatically.
- Lifetime management is declarative: Just say AddScoped and the framework handles creation and disposal.
- Easier to swap implementations: Change one registration line, and it propagates everywhere.
- Rich ecosystem: Middleware, filters, and frameworks deeply integrate with DI.
- Familiar to enterprise teams: Well-documented, widely taught pattern.
Go Advantages
- Compile-time safety: Missing dependencies are caught before your code ever runs, not during runtime.
- No "under the hood": Every dependency is visible in the code. New developers can trace the entire dependency graph by reading
main(). - No framework lock-in: Your DI isn't tied to any container or framework.
- Simpler mental model: There's no container lifecycle, no service descriptors, no scope validation errors. Just functions calling functions.
- Implicit interfaces: Decoupling without ceremony. Your implementation doesn't need to know about the interface it satisfies.
Both languages achieve the same goal. C# gives you automation and convention. Go gives you clarity and compile-time guarantees. The beauty of Go's approach is that dependency injection isn't a feature, it's just good code. You don't need a framework to practice it. You just pass dependencies as arguments.
As a C# developer, I am used to the safety net of a DI container. In Go, the safety net is the compiler itself, and it catches problems earlier with clearer error messages.
If you're a C# developer curious about Go, I'd encourage you to try the manual wiring approach with an open mind. You might find, as I did, that less framework sometimes means more control.
Thanks for reading.