Qué nos vendieron
En algún punto entre el auge de Spring, .NET Core y el club de patrones GOF, nos convencieron de que usar Inyección de Dependencias (DI) era la forma "correcta" de construir software.
Suena interesante y logico, desacoplar componentes, facilitar pruebas, mover configuraciones al arranque… todo muy bonito y suena hermoso en los cursos de arquitectura limpia.
Pero la realidad es que muchos sistemas terminan así:
Un
Program.cs
con 500 líneas de configuraciones que parecen un manifiesto político. Y el más pulcro lo aisla, arma una clase con todas sus registraciones. El choclo sigue igual.Services registrados como scoped, transient, singleton, factory de scoped, y nadie sabe por qué. Ni siquiera el que los escribió.
Casos de uso que reciben 10 servicios por constructor. Y cuando falla algo… buena suerte debuggeando en el laberinto de abstracciones.
"Tests unitarios" con más mocks que lógica real. Son más tests de configuración que de comportamiento.
¿Y para qué? Para inyectar algo que podrías haber instanciado con new()
sin romper nada ni despertar a los dioses de la arquitectura.
El costo oculto de la DI (que nadie te cuenta en los tutoriales)
Sobrecarga cognitiva: Entender el flujo de ejecución requiere leer el Program.cs
, los módulos de extensión, el archivo appsettings.json
versión D&D extendida, y un mega .MD para saber qué implementación se va a usar.
Trazabilidad nula: ¿Dónde se crea IMegaService
? ¿Cuál es la implementación real? ¿Alguien la reemplazó en tiempo de ejecución? ¿Por qué mi debugger me lleva a un proxy generado automáticamente?
Performance innecesaria: El resolver de servicios no es gratis. En ASP.NET Core podemos decir que se encuentra optimizado, pero igual vas a pagar el costo por algo que quizás ni lo vas a necesitar.
Es como contratar un sommelier para elegir la marca de agua.
Debugging del infierno: Cuando algo explota en el contenedor, los stack traces parecen novelas de Kafka. "Object reference not set to an instance of an object" nunca fue tan críptico.
Los números que nadie muestra
Simple, hagamos un experimento rápido. Una app simple que procesa 1000 requests:
// Sin DI - instanciación directa
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
var service = new SimpleService();
service.DoWork();
}
stopwatch.Stop();
// Resultado típico: ~2ms
// Con DI - resolución desde contenedor
var stopwatch2 = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
var service = serviceProvider.GetService<ISimpleService>();
service.DoWork();
}
stopwatch2.Stop();
// Resultado típico: ~8ms
¿Vale la pena pagar 4x más por la "flexibilidad"? A veces sí, a veces no. Como todo en programación Depende. El problema es que muchas veces (o nunca) nos preguntamos.
Cuándo NO usar DI (la lista prohibida)
1. Servicios sin estado, simples y triviales
public interface IGuidGenerator
{
Guid Generate();
}
public class GuidGenerator : IGuidGenerator
{
public Guid Generate() => Guid.NewGuid();
}
Esto es como crear una interfaz para sumar 2 + 2. ¿En serio necesitás mockear Guid.NewGuid()
?.
Te lo has tomando tan en pecho la frase "si a la interfaz, no a la implementación".
Te lo retruco, diseña en base al contexto o enfocandote a lo que necesitas y no lineamientos. Todo no tiene que ser tan estructurado.
2. Utilitarios que nunca van a cambiar
// ¿En serio?
public interface IStringHelper
{
bool IsNullOrEmpty(string value);
}
// Better:
public static class StringHelper
{
public static bool IsNullOrEmpty(string value) => string.IsNullOrEmpty(value);
}
3. Cuando es más fácil reemplazar con un parámetro
No todo tiene que ser un servicio. A veces, un parámetro en el constructor o en el método es más claro, más limpio, más testable.
// Recontra complicado. Cuantos proveedores de fecha vas a tener?
public class ReportGenerator
{
private readonly IDateTimeProvider _dateProvider;
public ReportGenerator(IDateTimeProvider dateProvider)
{
_dateProvider = dateProvider;
}
public Report Generate()
{
return new Report { CreatedAt = _dateProvider.Now };
}
}
// Simple, simple y simple
public class ReportGenerator
{
public Report Generate(DateTime? createdAt = null)
{
return new Report { CreatedAt = createdAt ?? DateTime.Now };
}
}
4. En bibliotecas reutilizables
No forcés a tus consumidores a usar tu container. Hacé tus clases autocontenidas. Imaginate descargar un NuGet package que te obliga a configurar 15 servicios antes de poder usarlo.
5. En tareas muy acotadas o consola de propósito específico
Un programita que parsea un archivo, llama una API y lo guarda… no necesita arquitectura en tres capas, DI, ni factory de command handlers.
Necesita funcionar primero.
Luego hablamos de escalar.
Respira hondo, y luego preguntate a vos mismo:
Quizas escale, va escalar, necesita escalar, o lo hago por si las dudas?.
El Dilema de las Zonas Grises
Hay casos donde es debatible usar DI. Acá es donde las guerras santas gritan en los code reviews:
Logging: ¿ILogger o static?
// Team ILogger:
public class UserService
{
private readonly ILogger<UserService> _logger;
public UserService(ILogger<UserService> logger)
{
_logger = logger;
}
public void DoSomething()
{
_logger.LogInformation("Doing something");
}
}
// Team Static:
public class UserService
{
private static readonly ILogger Logger = LogManager.GetCurrentClassLogger();
public void DoSomething()
{
Logger.LogInformation("Doing something");
}
}
Veredicto: Si tu app tiene logging estructurado y configuración compleja:
ILogger wins, flawless victory.
Si es logging básico para debugging, estatico y listo. No compliquemos las cosas.
Ejemplos: Buenos y Malos Usos de DI (Edición Extendida)
Mal uso #1: Inyectar lo trivial
public interface IGuidGenerator
{
Guid Generate();
}
public class GuidGenerator : IGuidGenerator
{
public Guid Generate() => Guid.NewGuid();
}
public class ReportService
{
private readonly IGuidGenerator _guidGenerator;
public ReportService(IGuidGenerator guidGenerator)
{
_guidGenerator = guidGenerator;
}
public void Run()
{
var id = _guidGenerator.Generate();
// 3 líneas de setup para hacer Guid.NewGuid()
}
}
Esto es engordar sin beneficio real, el flan con dulce de leche ya tiene dulce de leche. ¿Necesitas mas dulce?.
Realmente, ¿Vas a ponerte armar tu propia implementacion de IGuidGenerator?.
Solución simple:
public class ReportService
{
public void Run()
{
var id = Guid.NewGuid();
// Listo. Funciona. Siguiente.
}
}
Mal uso #2: Todo acoplado al contenedor
public class UserController
{
public UserController(
IUserService userService,
IMapper mapper,
ILogger<UserController> logger,
IHttpContextAccessor context,
IDateTimeProvider clock,
IValidator<UserDTO> validator,
ICacheService cache,
ITranslator translator,
IPermissionService permissions,
INotificationService notifications)
{
*** Algo mas????????
}
}
Solución usando el patrón Facade:
public interface IUserOperations
{
Task<UserResponse> CreateUserAsync(CreateUserRequest request);
Task<UserResponse> UpdateUserAsync(UpdateUserRequest request);
}
public class UserController
{
private readonly IUserOperations _userOps;
public UserController(IUserOperations userOps)
{
_userOps = userOps;
}
[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
var result = await _userOps.CreateUserAsync(request);
return Ok(result);
}
}
Buen uso #1: Servicio con dependencia real
public interface IMailService
{
Task SendAsync(string to, string subject, string body);
}
public class SmtpMailService : IMailService
{
private readonly SmtpConfiguration _config;
public SmtpMailService(SmtpConfiguration config)
{
_config = config;
}
public async Task SendAsync(string to, string subject, string body)
{
// Usa configuración SMTP real
// Puede fallar por red, autenticación, etc.
// Tiene sentido mockear esto
}
}
Esto sí tiene sentido: configuración externa, posible testeo, múltiples implementaciones (SMTP, una API custom (por cierto, mi opción preferida), fake para testing).
Buen uso #2: Repositorio con dependencia pesada
public interface IProductRepository
{
Task<Product> GetByIdAsync(Guid id);
Task SaveAsync(Product product);
}
public class SqlProductRepository : IProductRepository
{
private readonly IDbConnection _connection;
public SqlProductRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Product> GetByIdAsync(Guid id)
{
// Acceso real a base de datos
// Connection pooling, transacciones, etc.
}
}
Uso correcto: hay un recurso costoso que conviene inyectar, múltiples implementaciones posibles (SQL, MongoDB, in-memory para tests).
El Service Locator: El antipatrón que a veces no es tan anti
Gran dilema meterme con este tema... Tal vez debería dejarlo para otro artículo de debate. Pero bueno, ya que estamos: hablemos del polémico Service Locator.Sí, ese mismo patrón que todos los gurús de la arquitectura nos dicen que es “malísimo” y que “viola todos los principios de SOLID”.
Pero pará… respira nuevamente hondo: ¿seguir las reglas al pie de la letra no es, en sí mismo, una forma de anti-patrón? ¿No te diste cuenta?¿Tenés la vida resuelta? ¿O vas tomando decisiones a medida que surgen los problemas y las oportunidades?Exactamente. Esto es lo mismo: el sentido común no siempre viene en formato de manual.
Y no, no siempre es “no me llames, yo te llamo”. Hay veces que vos tenés que tomar la iniciativa y vos sos el responsable de decidir cuándo y cómo crear tus objetos.
Hay escenarios donde necesitás un control real y granular sobre el ciclo de vida de tus objetos, y la inyección automática se queda corta. O simplemente no aplica.Especialmente cuando estás lidiando como:
Transacciones distribuidas que requieren coordinación precisa => (No no voy a ponerme a discutir de otros patrones, ya suficiente con este)
Recursos costosos que necesitás crear y destruir en momentos específicos
Contextos de procesamiento que cambian dinámicamente
Background services que manejan múltiples scopes
Ejemplo: Procesamiento de órdenes con transacción distribuida
public class OrderProcessingService
{
private readonly IServiceProvider _serviceProvider;
public OrderProcessingService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task ProcessOrderAsync(Order order)
{
// Creamos un scope específico para esta operación
using var scope = _serviceProvider.CreateScope();
try
{
// Resolvemos los servicios cuando los necesitamos
// no antes, porque dependen del contexto de la orden
var paymentService = ResolvePaymentService(scope, order.PaymentMethod);
var inventoryService = scope.ServiceProvider.GetRequiredService<IInventoryService>();
var shippingService = ResolveShippingService(scope, order.ShippingRegion);
// Transacción distribuida
using var transaction = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
await paymentService.ChargeAsync(order.Total);
await inventoryService.ReserveItemsAsync(order.Items);
await shippingService.ScheduleDeliveryAsync(order);
transaction.Complete();
}
catch (Exception ex)
{
// El scope se dispone automáticamente y limpia recursos
throw new OrderProcessingException("Failed to process order", ex);
}
}
private IPaymentService ResolvePaymentService(IServiceScope scope, PaymentMethod method)
{
// Resolución dinámica basada en contexto
return method switch
{
PaymentMethod.CreditCard => scope.ServiceProvider.GetRequiredService<ICreditCardService>(),
PaymentMethod.PayPal => scope.ServiceProvider.GetRequiredService<IPayPalService>(),
PaymentMethod.BankTransfer => scope.ServiceProvider.GetRequiredService<IBankTransferService>(),
_ => throw new NotSupportedException($"Payment method {method} not supported")
};
}
}
¿Por qué acá Service Locator tiene sentido?
Control de lifecycle: Necesitas crear y destruir servicios en momentos precisos
Resolución dinámica: El tipo de servicio depende de datos de runtime
Scope management: Queres que todos los servicios compartan el mismo contexto transaccional
Resource cleanup: El using garantiza que los recursos se liberen correctamente al finalizar su uso. Detalle menor no?.
No liberar recursos puede tener consecuencias graves.
Desde simples fugas de memoria (memory leaks), hasta situaciones más complejas como corrupción de datos, locks que no se liberan, conexiones a base de datos que se quedan abiertas, o incluso fallos silenciosos que no se manifiestan hasta que el sistema está bajo carga.
Cuando usás recursos no administrados —como archivos, sockets, conexiones de red o instancias de base de datos—, confiar en el recolector de basura no es suficiente. El GC limpia memoria, pero no sabe cuándo ni cómo liberar correctamente recursos externos. Por eso existen interfaces como IDisposable
: para darte la posibilidad de tomar control explícito sobre el ciclo de vida de esos objetos.
Y ahí es donde using
entra a la cancha. No solo simplifica el código, sino que garantiza que el método Dispose()
se llame incluso si ocurre una excepción dentro del bloque. En otras palabras, using
es como un seguro: pase lo que pase, se ejecuta la limpieza.
Ignorarlo puede parecer inofensivo en ambientes chicos o durante pruebas, pero en producción... es cuestión de tiempo hasta que algo explote. Y cuando explota, no es fácil de rastrear.
Ejemplo 2: Background job processor
public class JobProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IJobQueue _jobQueue;
public JobProcessor(IServiceProvider serviceProvider, IJobQueue jobQueue)
{
_serviceProvider = serviceProvider;
_jobQueue = jobQueue;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var job = await _jobQueue.DequeueAsync(stoppingToken);
if (job != null)
{
// Cada job se procesa en su propio scope
// para evitar memory leaks y conflictos
using var scope = _serviceProvider.CreateScope();
try
{
var handler = ResolveHandler(scope, job.Type);
await handler.ProcessAsync(job);
}
catch (Exception ex)
{
// Log error, requeue job, etc.
}
// El scope se dispone y libera recursos
}
}
}
private IJobHandler ResolveHandler(IServiceScope scope, string jobType)
{
// Resolución dinámica basada en el tipo de job
var handlerType = Type.GetType($"MyApp.Jobs.{jobType}Handler");
return (IJobHandler)scope.ServiceProvider.GetRequiredService(handlerType);
}
}
¿Cuándo Service Locator NO es antipatrón?
Cuando necesitás resolución lazy de dependencias
En factory methods complejos
Para scope management manual
En plugin architectures dinámicas
Para resource pooling customizado
El Service Locator "responsable"
public class ResponsibleLocator
{
private readonly IServiceProvider _provider;
public ResponsibleLocator(IServiceProvider provider)
{
_provider = provider;
}
// Método explícito, no magia oculta
public T ResolveWithScope<T>(Action<T> action) where T : class
{
using var scope = _provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<T>();
action(service);
return service;
}
// Para casos async
public async Task<TResult> ResolveWithScopeAsync<T, TResult>(Func<T, Task<TResult>> func) where T : class
{
using var scope = _provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<T>();
return await func(service);
}
}
La diferencia clave: No estás pasando el Service Locator por todos lados como una dependencia global. Lo usás en puntos específicos donde necesitás control de lifecycle.
Alternativas a DI que funcionan (y nadie habla de ellas)
1. Factory Pattern clásico
public static class EmailServiceFactory
{
public static IEmailService Create(EmailProvider provider, IConfiguration config)
{
return provider switch
{
EmailProvider.Smtp => new SmtpEmailService(config.GetSmtpSettings()),
EmailProvider.SendGrid => new SendGridEmailService(config.GetSendGridKey()),
EmailProvider.Fake => new FakeEmailService(),
_ => throw new NotSupportedException($"Provider {provider} not supported")
};
}
}
// Uso:
var emailService = EmailServiceFactory.Create(EmailProvider.Smtp, configuration);
await emailService.SendAsync("test@test.com", "Hello", "World");
Ventajas: Simple, explícito, no necesita contenedor.
Desventajas: Más código boilerplate.
2. Builder Pattern para casos complejos
public class ReportBuilder
{
private IDataSource _dataSource;
private IFormatter _formatter;
private IExporter _exporter;
public ReportBuilder WithDataSource(IDataSource dataSource)
{
_dataSource = dataSource;
return this;
}
public ReportBuilder WithFormatter(IFormatter formatter)
{
_formatter = formatter;
return this;
}
public ReportBuilder WithExporter(IExporter exporter)
{
_exporter = exporter;
return this;
}
public Report Build()
{
return new Report(_dataSource, _formatter, _exporter);
}
}
// Uso:
var report = new ReportBuilder()
.WithDataSource(new SqlDataSource(connectionString))
.WithFormatter(new ExcelFormatter())
.WithExporter(new FileExporter())
.Build();
3. Functional Composition (para los hipsters)
public static class Pipeline
{
public static Func<T, TResult> Compose<T, TIntermediate, TResult>(
Func<T, TIntermediate> first,
Func<TIntermediate, TResult> second)
{
return input => second(first(input));
}
}
// Uso:
var processUser = Pipeline.Compose<User, ValidatedUser, ProcessedUser>(
user => ValidateUser(user),
validUser => ProcessUser(validUser)
);
var result = processUser(inputUser);
Testing: Más mocks ≠ Mejores tests
Llego la hora donde los TDD extremistas me van a crucificar, pero acá vamos:
El test pesadilla (todos los hemos escrito )
[Test]
public async Task CreateUser_WithValidData_ShouldCreateUser()
{
// Arrange (o "Arreglar el desastre")
var mockUserRepo = new Mock<IUserRepository>();
var mockEmailService = new Mock<IEmailService>();
var mockLogger = new Mock<ILogger<UserService>>();
var mockValidator = new Mock<IValidator<CreateUserRequest>>();
var mockPermissions = new Mock<IPermissionService>();
var mockCache = new Mock<ICacheService>();
var mockEventBus = new Mock<IEventBus>();
mockValidator.Setup(x => x.ValidateAsync(It.IsAny<CreateUserRequest>()))
.ReturnsAsync(new ValidationResult());
mockPermissions.Setup(x => x.CanCreateUser(It.IsAny<string>()))
.Returns(true);
mockUserRepo.Setup(x => x.SaveAsync(It.IsAny<User>()))
.Returns(Task.CompletedTask);
mockEmailService.Setup(x => x.SendWelcomeEmailAsync(It.IsAny<string>()))
.Returns(Task.CompletedTask);
// ... 20 líneas más de setup
var service = new UserService(
mockUserRepo.Object,
mockEmailService.Object,
mockLogger.Object,
mockValidator.Object,
mockPermissions.Object,
mockCache.Object,
mockEventBus.Object);
var request = new CreateUserRequest { Name = "John", Email = "john@test.com" };
// Act
await service.CreateUserAsync(request);
// Assert
mockUserRepo.Verify(x => x.SaveAsync(request), Times.Once);
mockEmailService.Verify(x => x.SendWelcomeEmailAsync(request), Times.Once);
// ... más verificaciones
}
Pregunta honesta: ¿Qué estás testeando acá? ¿La lógica de negocio o tu habilidad para configurar mocks?. Te recuerdo que no pudiste vencer a Malenia en Elden Ring y te desquitas con el pobre código.
El test más simple y directo
[Test]
public void CalculateDiscount_WithValidPercentage_ReturnsCorrectAmount()
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var result = calculator.Calculate(originalPrice: 100m, discountPercent: 0.15m);
// Assert
Assert.AreEqual(85m, result);
}
[Test]
public void ValidateEmail_WithInvalidEmail_ReturnsFalse()
{
// Arrange
var validator = new EmailValidator();
// Act
var result = validator.IsValid("not-an-email");
// Assert
Assert.IsFalse(result);
}
¿Se nota la diferencia? Estos tests son:
Fáciles de entender
Rápidos de escribir
Confiables
Testean comportamiento real
Te da mas tiempo para practicar contra Malenia.
Integration Tests: Los héroes incomprendidos
A veces, un integration test vale más que 50 unit tests con mocks:
[Test]
public async Task UserRegistration_EndToEnd_WorksCorrectly()
{
// Arrange
using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var request = new
{
Name = "John Doe",
Email = "john.doe@test.com",
Password = "SecurePassword123!"
};
// Act
var response = await client.PostAsJsonAsync("/api/users", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var user = await response.Content.ReadFromJsonAsync<UserResponse>();
user.Name.Should().Be("John Doe");
user.Email.Should().Be("john.doe@test.com");
// Verify user actually exists in database
var dbUser = await GetUserFromDatabase(user.Id);
dbUser.Should().NotBeNull();
}
Este test te dice si tu sistema realmente funciona, no si configuraste bien los mocks.
Cuándo DI sí vale la pena (para ser justos)
No todo es criticar. DI tiene su lugar:
1. Configuración compleja y múltiples ambientes
// appsettings.Development.json
{
"EmailProvider": "Fake",
"DatabaseProvider": "InMemory"
}
// appsettings.Production.json
{
"EmailProvider": "SendGrid",
"DatabaseProvider": "SqlServer"
}
// Program.cs
builder.Services.AddScoped<IEmailService>(provider =>
{
var config = provider.GetService<IConfiguration>();
var providerType = config["EmailProvider"];
return providerType switch
{
"SendGrid" => new SendGridEmailService(config.GetSendGridSettings()),
"Smtp" => new SmtpEmailService(config.GetSmtpSettings()),
"Fake" => new FakeEmailService(),
_ => throw new InvalidOperationException($"Unknown provider: {providerType}")
};
});
2. Recursos costosos con lifecycle management
// Conexion a base de datos, httpclient factory, etc.
builder.Services.AddSingleton<IHttpClientFactory, HttpClientFactory>();
builder.Services.AddScoped<IDbConnection>(provider =>
new SqlConnection(connectionString));
3. Cross-cutting concerns
// Decorators, interceptors, etc.
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IMyConfiguration>(mycustomconfig);
La regla de oro (mi visión)
Usá DI cuando:
Tenés múltiples implementaciones reales
La dependencia tiene configuración externa
Es un recurso costoso o con lifecycle complejo
Necesitás cross-cutting concerns
No uses DI cuando:
Es una clase simple sin dependencias externas
Solo vas a tener una implementación forever
Es más fácil pasar como parámetro
Estás creando interfaces "por las dudas"
Performance Tips (para los obsesivos)
Si ya decidiste usar DI, al menos hacelo eficiente:
1. Registrá como Singleton cuando sea posible
// Malo: crea nueva instancia cada vez
builder.Services.AddTransient<IExpensiveService, ExpensiveService>();
// Mejor: reutiliza instancia
builder.Services.AddSingleton<IExpensiveService, ExpensiveService>();
2. Evitá resolver servicios en loops
// Malo
for (int i = 0; i < 1000; i++)
{
var service = serviceProvider.GetService<IMyService>();
service.DoWork();
}
// Mejor
var service = serviceProvider.GetService<IMyService>();
for (int i = 0; i < 1000; i++)
{
service.DoWork();
}
3. Considerá usar factory para objetos con parámetros
public interface IOrderProcessorFactory
{
IOrderProcessor Create(OrderType type);
}
// En lugar de resolver cada vez
public class OrderService
{
private readonly IOrderProcessorFactory _factory;
public OrderService(IOrderProcessorFactory factory)
{
_factory = factory;
}
public async Task ProcessAsync(Order order)
{
var processor = _factory.Create(order.Type);
await processor.ProcessAsync(order);
}
}
Conclusión (la parte seria)
La DI no es mala. Pero su abuso sí. Usarla porque "así se hace en .NET" es como usar un taladro industrial con percursor para colgar un cuadrito en la pared.
El verdadero arte está en saber cuándo NO usarla. En construir soluciones que se entienden solas, sin necesidad de recorrer 14 archivos y un debugger para saber qué instancia llegó a tu servicio.
Preguntate siempre:
¿Esto realmente necesita ser inyectado?
¿Voy a tener múltiples implementaciones?
¿Es más fácil entender con o sin DI?
¿Mis tests son mejores con mocks o con la implementación real?
Si no sabés cuándo no usarla, probablemente tampoco sepás por qué la estás usando.
Regla de oro: Nadie lee, por lo tanto el mejor código es el que se entiende sin documentación y sin diagramas. El resto, es un plus.
Nota: Este artículo utiliza un enfoque con humor, porque a veces necesitamos aplicarle una dosis de humor a la vida cotidiana del desarrollo. No busca cuestionar el valor de la Inyección de Dependencias como patrón, sino promover un uso consciente y equilibrado. Si en algún momento te sentiste aludido, tal vez sea buen momento para revisar tu Program.cs con nuevos ojos.