Create your own
Lesson illustration

Improving C# Object Models with SOLID Design Principles

Hello. In the previous lesson, you refreshed how C# variables, parameter passing, nullability, boxing, and equality behave. That precision matters here: object-oriented design is ultimately about making valid states and dependencies explicit, rather than relying on developers to remember informal rules.

This lesson shifts from predicting snippets to a senior-interview task: critique a small object model, identify design risks, and propose a proportionate improvement. We will use a familiar business workflow: approving channel-fund reimbursement requests. The goal is not to apply every SOLID principle mechanically; it is to explain why encapsulation, polymorphism, interfaces, composition, and dependency inversion improve a particular design.

Priority: Must know. Allow roughly 40 minutes, including the two short resources and answering the interview prompts aloud before reading the model responses.


Start as an interviewer would: critique before redesigning

Consider this simplified model:

public class FundRequest
{
    public Guid Id { get; set; }
    public decimal RequestedAmount { get; set; }
    public string Status { get; set; } = "Draft";
    public List<ClaimLine> Claims { get; set; } = new();

    public virtual decimal CalculateApprovedAmount()
    {
        return RequestedAmount;
    }

    public virtual void SendPartnerNotification()
    {
        // Sends email directly
    }
}

public sealed class PercentageFundRequest : FundRequest
{
    public decimal Percentage { get; set; }

    public override decimal CalculateApprovedAmount()
    {
        return RequestedAmount * Percentage;
    }
}

public sealed class DraftFundRequest : FundRequest
{
    public override decimal CalculateApprovedAmount()
    {
        throw new InvalidOperationException(
            "A draft request cannot be approved.");
    }
}

public sealed class FundRequestService
{
    public async Task ApproveAsync(Guid requestId)
    {
        var repository = new EfFundRequestRepository();
        var sender = new SmtpNotificationSender();

        var request = await repository.GetRequiredAsync(requestId);

        request.Status = "Approved";
        var amount = request.CalculateApprovedAmount();

        await repository.SaveChangesAsync();
        sender.SendPartnerNotification(request, amount);
    }
}

Interview prompt: answer before continuing

Take two or three minutes and explain aloud:

What concerns do you see in this design? Which concerns are worth fixing now, and how would you improve it without over-engineering?

Try to organize your answer around state safety, inheritance, responsibilities, dependencies, and testability. Do not merely list SOLID acronyms.

Model critique

A strong answer identifies the following issues and connects each to a practical consequence.

1. The entity does not protect its own invariants.
Every property has a public setter. Any caller can approve a request by assigning a string:

request.Status = "Approved";

Nothing confirms that the request has claims, that it was submitted first, or that the approved amount is valid. Claims is also a mutable List<ClaimLine> exposed to every consumer, so callers can add or remove claim lines without the entity applying its rules.

This is an encapsulation problem. Encapsulation is not simply “make fields private.” It means the object owns and protects the rules that keep it valid.

2. Status is a fragile representation of a business state.
Strings permit typos, invalid transitions, and inconsistent casing. An enum would improve representation, but an enum alone does not enforce transitions. The real fix is intent-revealing behavior such as Submit() and Approve(...), which validate the current state before changing it.

3. The inheritance hierarchy mixes unrelated dimensions.
PercentageFundRequest describes a reimbursement calculation rule. DraftFundRequest describes a temporary lifecycle state. Neither is necessarily the enduring identity of the request.

A request may begin as a draft, be submitted, then approved using a percentage rule. If types model those facts, the object would need to change runtime type during its lifecycle, which ordinary inheritance does not support. If future rules combine “percentage reimbursement,” “annual cap,” “partner tier,” and “special promotion,” subclasses multiply rapidly.

4. DraftFundRequest violates substitutability.
A caller that receives a FundRequest can reasonably expect CalculateApprovedAmount() to return a calculation. With DraftFundRequest, the same operation throws because the operation itself is not meaningful. That is a warning that the base abstraction promises too much.

This is a practical form of the Liskov Substitution Principle: code written for the base type should not need special warnings, exception handling, or type checks for a derived type.

5. The entity has too many responsibilities.
The request is both a business object and an email sender. Sending email involves an external system, error handling, operational configuration, and possibly retries. Those are not intrinsic properties of a fund request.

6. FundRequestService directly constructs infrastructure.
The service depends on concrete EF Core and SMTP implementations. It cannot be tested without either a database and mail system or awkward workarounds. Swapping email delivery or persistence later requires editing the business workflow itself.

The first three issues are worth fixing immediately because they affect business correctness and maintainability. The last two are worth fixing when this is real application logic, because they improve testability and make infrastructure replaceable. Avoid a reflex to introduce an interface for every class; create an abstraction where a meaningful boundary or independently varying behavior exists.


Inheritance, interfaces, and composition: choose the relationship honestly

Inheritance is useful when a subtype genuinely is a base type and can honor all of the base type’s behavioral promises. For example, a framework base class may define a stable template method that derived implementations legitimately specialize.

Inheritance is a poor fit when it exists only to reuse a few fields, to share method signatures, or to represent features that can vary independently.

A useful diagnostic is:

  • Does the derived class need every meaningful behavior of the base class?
  • Can any caller use the derived object wherever it expects the base object, without special exceptions?
  • Will new requirements create combinations of features that a single inheritance chain cannot express?
  • Is the relationship really “has a policy/capability,” rather than “is a specialized kind of thing”?

The reimbursement rule is a has-a relationship. A request has an applicable reimbursement policy. That policy can vary independently of the request’s lifecycle and can be selected from program configuration.

Why Favor Object Composition Over Class Inheritance? A Deep Dive

Watch “Why Favor Object Composition Over Class Inheritance? A Deep Dive” by Zoran on C#. It illustrates how a seemingly sensible inheritance tree becomes brittle when independently variable capabilities must be combined.

First watch the inheritance limits. Focus on the moment where a new hybrid requirement forces duplicated state and behavior: that is the core reason class hierarchies can grow combinatorially. Then watch the composition redesign. Notice how movement capabilities become composed parts rather than ancestor classes. Treat the presenter’s nullable-property implementation as an illustration of composition, not as a universal model; in production, construction should still prevent nonsensical combinations.

Polymorphism does not require a deep class hierarchy

Polymorphism means a caller can invoke a common contract while the appropriate implementation supplies the behavior. An interface is often the cleanest contract when the implementations are alternatives rather than variants in one natural inheritance family.

For reimbursement, the common behavior is narrow and explicit:

public interface IReimbursementPolicy
{
    decimal CalculateApprovedAmount(FundRequest request);
}

Different policy objects implement that contract:

public sealed class PercentageWithCapPolicy : IReimbursementPolicy
{
    private readonly decimal _percentage;
    private readonly decimal _maximumAmount;

    public PercentageWithCapPolicy(
        decimal percentage,
        decimal maximumAmount)
    {
        if (percentage <= 0 || percentage > 1)
        {
            throw new ArgumentOutOfRangeException(
                nameof(percentage));
        }

        if (maximumAmount <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(maximumAmount));
        }

        _percentage = percentage;
        _maximumAmount = maximumAmount;
    }

    public decimal CalculateApprovedAmount(FundRequest request)
    {
        var calculatedAmount = request.RequestedAmount * _percentage;

        return Math.Min(calculatedAmount, _maximumAmount);
    }
}

public sealed class FixedAmountPolicy : IReimbursementPolicy
{
    private readonly decimal _fixedAmount;

    public FixedAmountPolicy(decimal fixedAmount)
    {
        if (fixedAmount <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(fixedAmount));
        }

        _fixedAmount = fixedAmount;
    }

    public decimal CalculateApprovedAmount(FundRequest request)
    {
        return Math.Min(_fixedAmount, request.RequestedAmount);
    }
}

The caller works with IReimbursementPolicy, not an if or switch that knows every policy subtype. Adding a new policy such as a tiered partner policy adds a focused implementation rather than modifying the request entity and risking regressions in existing rules.

This is composition in practical terms: a request collaborates with a policy object instead of inheriting its calculation behavior.

Useful nuance for interviews: interfaces represent a contract, not a mandatory layer. A simple value object or entity with one implementation does not automatically need an interface. Here, an interface is justified because calculation behavior has multiple plausible implementations and is expected to vary by program.


Refactor the entity: protect state before adding abstractions

The entity should own the lifecycle rules that define a valid fund request. Public setters become private state, and callers express intent through methods.

public enum FundRequestStatus
{
    Draft,
    Submitted,
    Approved
}

public sealed class ClaimLine
{
    public ClaimLine(decimal eligibleAmount)
    {
        if (eligibleAmount <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(eligibleAmount));
        }

        EligibleAmount = eligibleAmount;
    }

    public decimal EligibleAmount { get; }
}

public sealed class FundRequest
{
    private readonly List<ClaimLine> _claims = new();

    public FundRequest(Guid id, string partnerId)
    {
        if (id == Guid.Empty)
        {
            throw new ArgumentException(
                "An identifier is required.",
                nameof(id));
        }

        if (string.IsNullOrWhiteSpace(partnerId))
        {
            throw new ArgumentException(
                "A partner is required.",
                nameof(partnerId));
        }

        Id = id;
        PartnerId = partnerId;
        Status = FundRequestStatus.Draft;
    }

    public Guid Id { get; }
    public string PartnerId { get; }
    public FundRequestStatus Status { get; private set; }
    public decimal ApprovedAmount { get; private set; }

    public IReadOnlyCollection<ClaimLine> Claims
    {
        get
        {
            return _claims.AsReadOnly();
        }
    }

    public decimal RequestedAmount
    {
        get
        {
            return _claims.Sum(claim => claim.EligibleAmount);
        }
    }

    public void AddClaim(ClaimLine claim)
    {
        ArgumentNullException.ThrowIfNull(claim);

        if (Status != FundRequestStatus.Draft)
        {
            throw new InvalidOperationException(
                "Claims can be changed only while the request is a draft.");
        }

        _claims.Add(claim);
    }

    public void Submit()
    {
        if (Status != FundRequestStatus.Draft)
        {
            throw new InvalidOperationException(
                "Only a draft request can be submitted.");
        }

        if (_claims.Count == 0)
        {
            throw new InvalidOperationException(
                "At least one claim is required before submission.");
        }

        Status = FundRequestStatus.Submitted;
    }

    public void Approve(IReimbursementPolicy policy)
    {
        ArgumentNullException.ThrowIfNull(policy);

        if (Status != FundRequestStatus.Submitted)
        {
            throw new InvalidOperationException(
                "Only a submitted request can be approved.");
        }

        var calculatedAmount = policy.CalculateApprovedAmount(this);

        if (calculatedAmount < 0 ||
            calculatedAmount > RequestedAmount)
        {
            throw new InvalidOperationException(
                "The approved amount is outside the permitted range.");
        }

        ApprovedAmount = calculatedAmount;
        Status = FundRequestStatus.Approved;
    }
}

Several design choices matter more than the syntax:

  • FundRequestStatus makes the set of states finite and explicit.
  • AddClaim, Submit, and Approve describe business actions rather than exposing raw mutation.
  • The entity owns the lifecycle transition rules.
  • Claim lines cannot be appended directly through the public Claims property.
  • The reimbursement policy is supplied as a collaborator. The request does not need subclasses for each formula.

This entity is not “immutable.” It is deliberately mutable, but only through operations that preserve its invariants. That is often the right choice for a lifecycle-based domain entity tracked by EF Core.

A senior-level follow-up is worth mentioning:

If an approval must be auditable later, I would persist the policy identity or version and the inputs used for the decision, not just the final amount. Otherwise a later policy configuration change can make historical decisions difficult to explain.


Dependency inversion: keep business policy independent of EF Core and email

Dependency injection is the mechanism of supplying an object’s dependencies, commonly through its constructor. Dependency inversion is the design principle that high-level business logic should not compile against low-level implementation details.

The approval workflow needs persistence and notification capabilities, but it should not know whether persistence is EF Core with SQL Server, a test fake, or another provider. It should not know whether notification is email, a queue, or an external API.

Define abstractions around what the workflow needs:

public interface IFundRequestRepository
{
    Task<FundRequest> GetRequiredAsync(
        Guid requestId,
        CancellationToken cancellationToken);

    Task SaveChangesAsync(CancellationToken cancellationToken);
}

public interface INotificationSender
{
    Task SendApprovalAsync(
        FundRequest request,
        CancellationToken cancellationToken);
}

The application service depends only on these contracts:

public sealed class FundRequestApprovalService
{
    private readonly IFundRequestRepository _requests;
    private readonly IReimbursementPolicy _policy;
    private readonly INotificationSender _notifications;

    public FundRequestApprovalService(
        IFundRequestRepository requests,
        IReimbursementPolicy policy,
        INotificationSender notifications)
    {
        _requests = requests;
        _policy = policy;
        _notifications = notifications;
    }

    public async Task ApproveAsync(
        Guid requestId,
        CancellationToken cancellationToken)
    {
        var request = await _requests.GetRequiredAsync(
            requestId,
            cancellationToken);

        request.Approve(_policy);

        await _requests.SaveChangesAsync(cancellationToken);

        await _notifications.SendApprovalAsync(
            request,
            cancellationToken);
    }
}

The workflow is now easy to unit test: provide an in-memory or fake repository, a known policy, and a fake notification sender. The service’s core test asks whether the request was approved, saved, and notified; it need not start SQL Server or send email.

A real production design must also decide what happens if the database save succeeds but notification fails. That reliability concern will later lead into background processing and durable messaging. For now, the key point is that moving external communication out of the entity makes the concern visible and independently testable.

The Clean Architecture onion view places entities and interfaces in the Application Core, while UI and infrastructure implementations such as SQL repositories and cloud or email services sit outside and depend inward on core abstractions.

Common web application architectures - .NET

Read the relevant parts of Microsoft Learn’s “Common web application architectures.” It connects separation of concerns to testability and explains why Clean Architecture places interfaces and business logic at the center rather than making them depend on EF Core or external services.

In the “What are layers?” section, read the explanation of layers. Focus on how dependency restrictions limit the impact of change. Then move to “Clean architecture” and read the dependency direction. Finally, in “Organizing code in Clean Architecture,” read the Application Core description, then scan the following Infrastructure and UI Layer lists. Pay particular attention to the composition root in Program.cs: it is the deliberate place where interfaces are connected to concrete implementations.

At application startup, the composition root makes those connections:

builder.Services.AddScoped<
    IFundRequestRepository,
    EfFundRequestRepository>();

builder.Services.AddScoped<
    INotificationSender,
    SmtpNotificationSender>();

builder.Services.AddScoped<IReimbursementPolicy>(
    serviceProvider =>
    {
        return new PercentageWithCapPolicy(
            percentage: 0.50m,
            maximumAmount: 5_000m);
    });

The outer application layer knows about concrete implementations because it must assemble the running application. The core workflow does not. That direction is the important distinction.


A concise senior-interview answer structure

For an object-model critique, avoid jumping immediately into patterns. Use this five-part verbal structure:

  1. Name the business responsibility.
    “A fund request owns claims and its lifecycle; reimbursement calculation is a rule that may vary by program.”

  2. Identify the failure mode.
    “Public setters permit invalid states, and a draft subtype throws from a base operation, so callers cannot safely treat all requests uniformly.”

  3. Propose the smallest coherent redesign.
    “I would encapsulate state transitions in the entity and extract variable calculation behavior behind IReimbursementPolicy.”

  4. Explain the dependency boundary.
    “The application service depends on repository and notification abstractions; EF Core and SMTP remain infrastructure implementations registered at startup.”

  5. State a trade-off.
    “I would not introduce interfaces for stable one-off classes. The policy abstraction is justified because calculation rules vary and need isolated tests.”

Interview prompt: deliver a 90-second answer

Answer aloud before reading on:

A product owner says that a fund request can use different reimbursement rules based on partner tier, campaign type, and promotion. A teammate proposes GoldPercentageFundRequest, SilverPercentageFundRequest, GoldFixedFundRequest, and similar subclasses. What would you recommend, and what questions would you ask before implementing it?

Model answer

I would avoid encoding combinations of rules into request subclasses because the number of types grows with every new dimension. Partner tier, campaign type, and promotion are inputs to a decision, not necessarily different identities of a fund request.

I would keep one FundRequest entity that owns claim data and valid state transitions. I would model reimbursement calculation as a focused policy or strategy, selected based on explicit program configuration. Before implementing it, I would clarify whether rules can be combined, whether the policy is fixed at submission or can change before approval, whether exceptions require manual approval, and what audit record is required.

I would also keep policy selection separate from policy execution. A IReimbursementPolicy calculates a result; a resolver or application service can choose the applicable policy from partner and campaign information. That separation keeps individual rules testable and avoids turning one interface into a large conditional engine.


Key takeaways

  • Encapsulation means protecting business invariants through controlled operations, not merely using private fields.
  • Use inheritance for a stable, truthful is-a relationship in which derived types honor the entire base contract.
  • Use composition when behavior varies independently, especially when future requirements create combinations of capabilities or rules.
  • Interfaces enable polymorphism when callers need a focused contract and multiple implementations are meaningful.
  • Dependency inversion keeps core workflows dependent on abstractions, while EF Core, SMTP, and other infrastructure implementations depend on the core and are wired together in the composition root.
  • In an interview, explain the concrete risk, the smallest suitable redesign, and the trade-off. Pattern names alone are not a senior-level answer.

Next, you will practice choosing the appropriate generic collection for lookup, uniqueness, ordering, queueing, and thread-safe access—another common area where interviewers care more about reasoning than memorized definitions.

Can't find a good explanation? Sign up and we'll make it for you

Sign up