The 'uses' relationship: temporary dependencies versus stored associations, dependency direction and inversion, and DI in .NET.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
A dependency is the weakest relationship: class A uses class B to do its job β as a parameter, a local variable, a return type, a static call or an injected service β so a change to B can force a change to A. An association is stronger and structural: A holds a persistent link to B as part of its state. Then the senior part: the direction of dependencies determines what breaks when things change, which is what the Dependency Inversion Principle and DI are about.
// Dependency: uses a calculator temporarily (parameter); no lasting link
public decimal Total(Order order, ITaxCalculator tax) => order.Subtotal + tax.For(order);
// Association: Order structurally holds its customer reference as state
public sealed class Order { public CustomerId CustomerId { get; } }
| Dependency | Association | |
|---|---|---|
| Meaning | "uses" | "knows/has" |
| Duration | During a call (or as a collaborator) | For the object's lifetime |
| UML | Dashed arrow | Solid line |
| Example | IEmailSender used by a service, DateTime.Now called, File.ReadAllText used |
Order.Customer, Team.Players |
Injected services stored in readonly fields are technically associations in UML terms, but in design discussions we call them dependencies because they're collaborators the class uses, not part of its domain state.
new SmtpClient() inside a method, static calls (DateTime.UtcNow, File.*, HttpContext.Current-style ambient context), service locator (provider.GetService<T>() deep in code), global singletons.Hidden dependencies make code hard to test (you can't control time or the file system) and hard to reason about (the constructor lies about what the class needs).
In a naive layered design, high-level policy depends on low-level detail:
OrderService ---> SqlOrderRepository ---> SQL Server
(business) (infrastructure)
Every infrastructure change (new database, new email provider) ripples into business code, and business code can't be tested without infrastructure.
The Dependency Inversion Principle: high-level modules shouldn't depend on low-level modules; both should depend on abstractions; abstractions shouldn't depend on details. Concretely, the abstraction is owned by the high-level module:
Application: OrderService ---> IOrderRepository (defined here)
^
Infrastructure: SqlOrderRepository (implements it)
Source-code dependencies now point toward the business core (Clean/Onion/Hexagonal architecture). The domain and application layers compile without infrastructure; infrastructure plugs in.
DI is the mechanism that supplies dependencies from outside instead of the class creating them:
public sealed class OrderService(IOrderRepository orders, IPaymentGateway payments, TimeProvider clock) { ... }
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddHttpClient<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<OrderService>();
Program.cs) is the only place that knows concrete types.FakeTimeProvider, in-memory repository).A billing service called DateTime.Now in a dozen places and newed up an SmtpClient inside its methods. Month-end tests were impossible ("what happens on 31 March?") and a timezone bug β servers in UTC after migrating to Azure, invoices dated a day early for Australian customers β went unnoticed. We injected TimeProvider (with an explicit Australia/Sydney conversion) and an IEmailSender, which made date logic testable with FakeTimeProvider and surfaced the timezone bug in a unit test.
Checks understanding of coupling, DIP and practical DI in .NET.
TimeProvider.DateTime.Now and new of infrastructure, or puts interfaces next to implementations in infrastructure.From loosest to tightest:
Good design keeps dependencies at the data/stamp level, through narrow interfaces.
Robert C. Martin's package principles:
Architecture tests (NetArchTest, ArchUnitNET) can enforce "Domain must not reference Infrastructure" in CI.
ServiceCollection of ServiceDescriptors; BuildServiceProvider creates resolvers (compiled expression or IL-emitted factories for speed).ValidateScopes and ValidateOnBuild (default in Development) detect it.[FromKeyedServices("stripe")]) for multiple implementations of one interface.IServiceProvider injected everywhere): it hides dependencies and defers failures to run time.Common hidden dependencies and their abstractions:
| Hidden dependency | Abstraction |
|---|---|
DateTime.Now/UtcNow, Task.Delay, timers |
TimeProvider (.NET 8), FakeTimeProvider in tests |
Guid.NewGuid() for IDs in logic |
an ID generator interface when determinism matters |
Random |
inject Random or a seed |
| File system | IFileProvider, System.IO.Abstractions |
| Environment variables / config | IOptions<T> |
HttpClient created per call |
IHttpClientFactory / typed clients |
UML shows dependencies as dashed arrows with stereotypes (Β«useΒ», Β«createΒ», Β«callΒ»). Visual Studio's code metrics report class coupling (number of unique types a class depends on); values above ~30 for a class are a smell. NDepend and Rider's dependency diagrams visualise project and namespace dependencies.
You don't need an interface for every dependency. Stable, pure dependencies (the BCL, Money value objects, domain services without I/O) can be used directly. Invert dependencies at volatility and I/O boundaries: databases, external APIs, messaging, time, file system.
DateTime.Now, new HttpClient, File. and static service calls; list hidden dependencies.NetArchTest rules ensuring the domain project doesn't reference EF Core or ASP.NET Core.ValidateOnBuild and fix any captive dependencies.DateTime.UtcNow with TimeProvider in one service and write a month-end test.// Application layer owns the abstractions (dependency inversion)
public interface IInvoiceRepository
{
Task<IReadOnlyList<Invoice>> GetDueAsync(DateOnly asOf, CancellationToken ct);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct);
}
// Policy class: all dependencies explicit, none created inside
public sealed class InvoiceRunner(IInvoiceRepository invoices, IEmailSender email, TimeProvider clock)
{
public async Task<int> RunAsync(CancellationToken ct)
{
var today = DateOnly.FromDateTime(clock.GetLocalNow().DateTime); // no DateTime.Now
int sent = 0;
foreach (var invoice in await invoices.GetDueAsync(today, ct))
{
await email.SendAsync(invoice.Email, $"Invoice {invoice.Number}", Format(invoice), ct);
sent++;
}
return sent;
}
private static string Format(Invoice i) => $"Amount due: {i.Amount:C}"; // pure helper: fine as static
}
public sealed record Invoice(string Number, string Email, decimal Amount);
// Infrastructure implements the abstraction
public sealed class SqlInvoiceRepository(AppDb db) : IInvoiceRepository
{
public async Task<IReadOnlyList<Invoice>> GetDueAsync(DateOnly asOf, CancellationToken ct) =>
await db.Invoices.Where(i => i.DueDate <= asOf)
.Select(i => new Invoice(i.Number, i.Email, i.Amount))
.ToListAsync(ct);
}
// Composition root
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IInvoiceRepository, SqlInvoiceRepository>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<InvoiceRunner>();
// Test: control time without waiting for month end
// var clock = new FakeTimeProvider(new DateTimeOffset(2026, 10, 31, 9, 0, 0, TimeSpan.FromHours(11)));
What happens: classes call static APIs, new up services, or use a service locator internally.
Why it is wrong: constructors lie about requirements; code is hard to test and change.
Do instead: inject dependencies explicitly through constructors.
What happens: IOrderRepository lives in the infrastructure project next to SqlOrderRepository.
Why it is wrong: the application still depends on infrastructure; nothing is inverted.
Do instead: define the interface in the consuming (application/domain) layer.
IServiceProvider everywhereWhat happens: classes resolve what they need at run time. Why it is wrong: service locator hides dependencies and moves errors to run time. Do instead: inject specific dependencies; use factories only where creation must be deferred.
What happens: singletons depend on scoped services such as DbContext.
Why it is wrong: scoped instances live forever and are shared across requests.
Do instead: match lifetimes or use IServiceScopeFactory in singletons.
What happens: classes with 10+ constructor dependencies. Why it is wrong: indicates too many responsibilities. Do instead: split the class or group cohesive dependencies behind a facade.
What happens: interfaces wrapping Math, string helpers or value objects.
Why it is wrong: indirection without benefit.
Do instead: invert only volatile/I/O dependencies.
What happens: OrderService depends on InvoiceService, which depends on OrderService.
Why it is wrong: tight coupling, DI resolution failures and hard-to-change code.
Do instead: extract shared logic, use events, or restructure responsibilities.
What happens: business rules use DateTime.Now and machine time zones.
Why it is wrong: untestable date logic and time-zone bugs after cloud migrations.
Do instead: inject TimeProvider and convert explicitly to the business time zone.
Seniors manage dependencies as the main driver of coupling and architecture.
| Practice | Effect |
|---|---|
| Interfaces owned by the consumer | dependencies point inward |
| Explicit constructor dependencies | visible, testable design |
TimeProvider, IOptions<T>, IHttpClientFactory |
no hidden environment coupling |
Scope validation and ValidateOnBuild |
lifetime bugs caught at startup |
| Architecture tests (NetArchTest, ArchUnitNET) | layering enforced in CI |
| Watch constructor size | early signal of SRP violations |
They also avoid over-abstracting stable dependencies: string, List<T> or a pure static helper are dependencies too, and wrapping them adds no value.
Association is a lasting structural relationship where one object knows about another and can reach it, usually through a field or property: a Customer places Orders, a Teacher teaches Courses. It is the general relationship; aggregation and composition are stronger special cases that add ownership rules, and dependency is a weaker, temporary use. An association has multiplicity (one-to-one, one-to-many, many-to-many) and direction: unidirectional (Order.Customer only) or bidirectional (Customer.Orders and Order.Customer). In C# it appears as a reference property, a collection, or just an ID; in EF Core as navigation properties and foreign keys. Prefer unidirectional associations and IDs across aggregate boundaries; bidirectional links must be kept in sync and create cycles for serialisers.
Abstraction means showing only what a caller needs and hiding how it is done: an IPaymentGateway.ChargeAsync(amount) hides HTTP calls, retries and provider formats. Encapsulation protects an object's state and invariants; abstraction simplifies how others think about and use something. In C# you express abstractions with interfaces, abstract classes and well-named public methods. A good abstraction is named in the caller's terms, small, stable, and has more than one real reason to exist. A leaky abstraction forces callers to know the hidden details (provider-specific exceptions, SQL in a repository method name). A premature abstraction is an interface invented for one implementation that never changes, adding indirection without value.
How to tell it (STAR)
**Situation:** At [company] after a hosting move, our billing job sent invoices a day early to [Australian] customers. **Task:** I was asked to fix it and make sure date logic could be tested. **Action:** The job used `DateTime.Now`, a hidden dependency on the server's time zone, which was now UTC. I injected `TimeProvider`, made the business time zone explicit, and replaced the inline `SmtpClient` with an injected sender. Then I wrote tests that set the clock to month ends and daylight saving changes. A banned-API analyzer now blocks `DateTime.Now` in business code. **Result:** Invoices went out on the right day, we got [N] new boundary tests that run on any day, and [one more] time zone bug was caught by the analyzer before release.
Last updated 30 Oct 2026.