BrandGhost
Composition in C#: Owning Versus Borrowing Dependencies
All articles

Composition in C#: Owning Versus Borrowing Dependencies

Learn composition in C# through dependency ownership, shared references, and disposal boundaries, including why borrowers should not dispose injected services.

The ownership questions in the examples differ from simply holding a reference:

Relationship Cleanup question
Wrapper creates and owns its child Does the wrapper's lifetime include the child's disposal?
Consumer borrows a shared dependency Who owns the longer-lived dependency?
Container creates a disposable service At which container or scope boundary is it cleaned up?
Caller supplies an existing instance What explicit agreement assigns disposal responsibility?

Composition in C# tells you that an object collaborates with another object. It does not, by itself, tell you who controls the collaborator's lifetime. Holding a dependency reference and owning that dependency are different responsibilities.

That difference becomes concrete with disposable resources. A component may create and clean up a resource itself. Another component may borrow the same kind of resource from a caller or container. Both contain references, but only one may be responsible for ending the resource's usable lifetime.

This guide focuses on that ownership boundary rather than repeating a general composition introduction. The goal is to recognize owners, borrowers, and explicit cleanup responsibilities when reading ordinary C# objects.

Example scope: The two independent console programs target .NET 10 and have not been compiled or executed. Their behavior is reasoned from current Microsoft documentation. They illustrate sequential ownership, not thread safety or production file handling.

Composition in C# does not establish ownership

Microsoft's object-reference explanation states that assigning a class reference to another variable makes both variables refer to the same object. It does not create a private copy for the receiver.

Constructor parameters and fields participate in that same reference model. If a caller passes a collaborator into a constructor, the caller may still have a reference to it. Another consumer may also have a reference. The receiver cannot infer exclusive control from the fact that it stores the value.

Here, ownership means responsibility for arranging cleanup at the appropriate boundary. Borrowing means permission to use a collaborator without taking over that cleanup responsibility. These are explanatory terms for the examples, not built-in exclusive-ownership types in ordinary C#.

The existing composition examples explain objects collaborating without requiring inheritance. Ownership adds a different question: after the collaboration finishes, which participant is allowed and required to dispose the resource?

Do not answer that question by looking only for IDisposable in a constructor signature. The interface provides a cleanup operation. It does not identify which of several reference holders should call it.

A useful ownership explanation follows the creation path, the usage boundary, and the eventual cleanup path. When those three points belong to different objects, the contract needs to say how responsibility is divided.

Owning a resource usually includes its cleanup

Microsoft's dispose implementation guidance explains cascading disposal when a containing class owns a disposable instance. It also notes that creating and storing a disposable child commonly makes the containing class responsible for cleanup.

The following example makes that responsibility explicit. OwnedBuffer creates its own memory stream. It does not accept an externally shared stream. It exposes a small writing operation and owns the cleanup path for the stream it created.

using System;
using System.IO;

namespace OwnedBufferExample;

internal static class Program
{
    private static void Main()
    {
        using var buffer = new OwnedBuffer();
        buffer.Append(65);
        buffer.Append(66);

        Console.WriteLine(buffer.Length);
    }
}

public sealed class OwnedBuffer : IDisposable
{
    private readonly MemoryStream _stream = new();
    private bool _disposed;

    public long Length
    {
        get
        {
            EnsureUsable();
            return _stream.Length;
        }
    }

    public void WriteByte(byte value)
    {
        EnsureUsable();
        _stream.WriteByte(value);
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _stream.Dispose();
        _disposed = true;
    }

    private void EnsureUsable()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(nameof(OwnedBuffer));
        }
    }
}

The stream is an implementation detail. The owner does not expose it as a freely shared mutable dependency. The program owns the wrapper's scope and arranges for the wrapper to be disposed. The wrapper cascades cleanup to its child.

Microsoft's MemoryStream documentation describes memory-backed stream behavior and demonstrates writing bytes. It also notes that this type has no resources requiring disposal. It is a teaching stand-in here: the sample illustrates the wrapper's cleanup responsibility and explicit closed state, not deterministic reclamation of backing memory.

The explicit disposed check describes the wrapper's own post-disposal behavior. The example rejects new operations after disposal rather than relying on every underlying API to fail in the same way. Repeated wrapper disposal returns without repeating the child cleanup.

Microsoft recommends idempotent disposal: subsequent calls should do nothing. The boolean check demonstrates that idea for sequential use. It is not a synchronization mechanism and does not claim safe concurrent disposal or concurrent writes.

The wrapper is sealed and owns a managed disposable child. The guidance explains that a finalizer is unnecessary when a type references only managed objects. This example therefore does not add a finalizer or pretend that one is required for every disposable wrapper.

Using arranges cleanup, not ownership discovery

The C# using statement documentation describes disposal when control leaves a using block, including when an exception occurs. A using declaration disposes at the end of its containing scope.

That explains the cleanup arrangement in the owned-buffer program. The local scope creates the wrapper, uses it, and disposes it at the scope boundary. The wrapper then cleans up its owned child.

However, using does not investigate whether you have permission to dispose an instance. Applying it to a borrowed reference would still arrange a disposal call. The syntax performs cleanup; the ownership contract determines whether that cleanup belongs to you.

This is an important reading habit. When you see using var dependency = ..., inspect where the value came from. A newly created resource and a container-resolved shared service may have different ownership expectations even if both implement the same interface.

The same distinction applies to asynchronous cleanup. Microsoft documents await using for asynchronous disposable instances. It changes how cleanup is awaited, not who owns the collaborator or whether a borrower is allowed to end its lifetime.

Borrowers use resources without ending their lifetime

Microsoft's dispose guidance explicitly distinguishes a disposable field that is not owned by its containing object. Merely holding that field does not require the receiver to implement disposal for the collaborator.

The next program gives a caller ownership of a memory stream and lends it to a small writer. The writer accepts a writable stream and writes at its current position, not necessarily its end. It does not promise append-only behavior or dispose the borrowed stream.

using System;
using System.IO;

namespace BorrowedStreamExample;

internal static class Program
{
    private static void Main()
    {
        using var destination = new MemoryStream();
        var writer = new ByteWriter(destination);

        writer.WriteByte(65);
        writer.WriteByte(66);

        Console.WriteLine(destination.Length);
    }
}

public sealed class ByteWriter
{
    private readonly Stream _destination;

    public ByteWriter(Stream destination)
    {
        if (destination is null)
        {
            throw new ArgumentNullException(nameof(destination));
        }

        if (!destination.CanWrite)
        {
            throw new ArgumentException(
                "Destination must be writable.",
                nameof(destination));
        }

        _destination = destination;
    }

    public void Append(byte value)
    {
        _destination.WriteByte(value);
    }
}

The example's contract is that the caller keeps the destination usable while the writer is in use and cleans it up afterward. The caller can inspect the destination because the writer has not ended its lifetime.

The constructor check establishes a starting requirement: the supplied stream must be writable at that moment. It does not promise that the stream will stay writable forever. The owner could dispose it later, or some other allowed operation could change its usable state.

That limitation is not a reason to hide every reference or duplicate every resource. It is a reason to describe borrowing honestly. A borrower relies on the owner's lifetime boundary rather than manufacturing a guarantee from a constructor check.

The writer has no independent disposable resource of its own. It therefore does not implement IDisposable merely to cascade cleanup to the borrowed stream. Adding that cascade would conflict with this example's stated ownership contract.

For application boundaries, dependency inversion provides additional context about collaborating through a suitable abstraction. The stream parameter here expresses a capability boundary, while the prose describes the separate ownership boundary.

Readonly references still permit shared effects

Both examples store their collaborator in a readonly field. Microsoft documents that readonly reference fields prevent replacement, not mutation of the referenced object.

The borrower can still write to its stream through that field. The owner can still dispose the same stream through another reference. Readonly therefore does not make the stream immutable, prevent shared effects, or transfer cleanup responsibility.

The distinction is particularly useful in code reviews. “The dependency is readonly” explains why an ordinary method cannot replace the field after construction. It does not explain why another reference holder cannot alter or dispose the dependency.

Constructor injection also does not establish exclusive ownership. Passing a reference into an object makes the collaboration explicit, which is useful. But the creation and disposal contract still needs to explain whether the receiver owns, borrows, or obtains some documented transfer of responsibility.

Ordinary field syntax in these samples does not enforce exclusive borrowing. The examples rely on their stated usage boundaries. Readers should not infer stronger lifetime or aliasing guarantees than the declarations provide.

Container-created dependencies belong to the container's cleanup boundary

Microsoft's current dependency injection guidelines state that the container is responsible for cleanup of the services it creates. A receiver should not dispose a container-resolved dependency itself.

That makes a container-created service analogous to a borrowed collaborator from the consumer's perspective. A service can use the dependency for its work without becoming the owner of the dependency's lifetime.

The guideline is more precise than “injected objects are never disposed.” The objects are disposed, but at the container's lifetime boundary rather than opportunistically by each consumer. Scoped and transient container-created disposable services are cleaned up at the scope in which they were resolved; singleton services are cleaned up with the container.

Receiving a disposable dependency does not, by itself, require the receiver to implement IDisposable. A receiver may still own a separate disposable resource, but that is another responsibility and another cleanup path.

The existing explanation of interfaces in application code introduces collaboration boundaries. Lifetime ownership is a separate concern from whether the dependency is referenced through an interface or concrete class.

This article intentionally avoids a registration tutorial. Knowing the syntax of a registration does not replace understanding which party created the instance and which lifetime boundary will dispose it.

Externally created instances are a different case

The same Microsoft DI guideline distinguishes registering an already-created instance from asking the container to create a service. For an instance supplied directly to registration, the framework does not automatically dispose it; the developer remains responsible for cleanup.

That exception is important. “Everything in the container is owned by the container” is too broad. The documented creation path matters. So does the difference between a registration that supplies an existing instance and one through which the container creates the service.

Do not convert that exception into permission for every consumer to dispose the object. An externally created shared instance still needs one clear owner and a cleanup boundary. Its borrowers may have the same no-disposal obligation as the writer example.

The distinction can be explained without exposing registration details to every collaborator. The application composition point can establish the lifetime contract, while consumers focus on their own work.

For deeper contract design, the article on plugin contracts and interfaces provides another collaboration context. Capability and lifecycle expectations both deserve explicit descriptions; one does not automatically imply the other.

Ownership and borrowing have different tradeoffs

Before assigning cleanup responsibility, make the relationship explicit:

  1. Identify who created the instance and whether an ownership transfer was agreed.
  2. Identify any other consumers that share its lifetime.
  3. Locate the documented cleanup boundary and what happens after it ends.

These questions organize the review; they do not let a reference or constructor parameter enforce an ownership contract.

Creating an owned resource inside a component gives the component a clear cleanup path and control over construction. The tradeoff is coupling construction to that component and making external sharing less natural.

Borrowing separates resource creation from use and allows the owner to define a larger lifetime boundary. The tradeoff is that the borrower depends on a contract about continued usability and does not control every possible alias.

An ownership transfer can be another explicit contract, but it should not be silently inferred from an ordinary constructor parameter. If transfer is part of an API, the explanation should state when it happens and what happens if construction fails.

Factory-created resources introduce a similar question. The factory may create a caller-owned resource, or it may return something managed elsewhere. A return type alone does not tell the reader which cleanup obligation applies.

These are conceptual tradeoffs, not a ranking of patterns. An owner is not inherently better designed than a borrower. The important property is that creation, use, and cleanup responsibilities agree.

The discussion of interface drawbacks is useful here too. Adding abstraction can clarify a capability boundary, but an interface cannot compensate for an unexplained ownership contract.

Frequently asked questions

What does ownership mean in composition in C#?

In these examples, it means responsibility for arranging cleanup at the appropriate lifetime boundary. The owned wrapper creates and disposes its stream. The borrower merely uses a caller-owned stream. Ordinary reference fields do not themselves enforce exclusive ownership or prevent other references from existing.

Should every class with an IDisposable field implement IDisposable?

No. Microsoft distinguishes owning a disposable child from merely holding a reference to it. An owner commonly cascades disposal; a borrower should not take over that cleanup automatically. A receiver might have a different owned resource requiring disposal, but that does not change its borrowed dependency's ownership.

Does constructor injection transfer ownership?

Not by itself. It makes the dependency available to the receiver, but the caller can still retain a reference. A documented transfer contract could assign responsibility, while container-created dependencies follow the container's cleanup boundary. The parameter syntax alone is not an ownership declaration.

Should a service dispose an injected container-created dependency?

Microsoft's DI guidelines say the receiver should not call disposal on that dependency. The container cleans up services it creates according to their lifetime. A consumer's operation ending is not automatically the dependency's lifetime ending, particularly when another consumer shares the same instance.

Does using prove that an object belongs to the caller?

No. It arranges a disposal call at a scope boundary. Whether that call is appropriate depends on the ownership contract. Applying using to a borrowed resource can still end a lifetime that another party manages. Trace where the resource came from before treating cleanup syntax as evidence of ownership.

Does readonly prevent a borrowed dependency from being disposed?

No. A readonly reference field cannot ordinarily be replaced after construction, but the referenced object's state and lifetime can still change. Another reference holder can act on the same instance. Borrowing depends on a stated lifetime arrangement, not on readonly providing deep immutability or exclusive access.

Explain the lifetime alongside the relationship

Composition in C# becomes clearer when a dependency's capability and lifetime are described separately. The reference tells a component what it can use. The ownership contract tells it whether it must clean up the resource or leave that responsibility elsewhere.

The two examples express different, valid responsibilities. One owns a child and cascades cleanup. The other borrows a destination and leaves disposal to its caller. Container-managed services add a documented cleanup boundary, with an important distinction for externally created instances.

Those boundaries matter more than how tidy the field declaration looks. A readonly dependency is still a reference, and a disposable dependency is not automatically yours to dispose. Clear composition explains both collaboration and responsibility.

Get the Dev Leader Newsletter

C#, .NET, and software engineering content every Saturday.

Subscribe to the newsletter
An error has occurred. This application may no longer respond until reloaded. Reload