Skip to main content
ANVISoftware Solutions
Lesson 29 of 62Intermediate20 min

Interfaces

By the end of this lesson

Define a contract independent of implementation, and explain why this enables testing.

An interface is a list of members a type promises to provide, with no code behind any of them. It says what can be done and stays silent on how.

That sounds thin, and the value is not obvious until you see what it lets you replace. The payoff this lesson is aiming at is concrete: being able to test the logic that marks an invoice as paid without a database anywhere near it.

By convention C# interface names begin with a capital I. It is only a convention, and it is followed near-universally.

A contract, a real implementation, and a class that depends on the contract
C#
public interface IInvoiceStore
{
    Invoice? FindByReference(string reference);
    void Save(Invoice invoice);
}

public class SqlInvoiceStore : IInvoiceStore
{
    // Talks to the real database. Omitted here; the point is that it exists.
    public Invoice? FindByReference(string reference) => throw new NotImplementedException();
    public void Save(Invoice invoice) => throw new NotImplementedException();
}

public class InvoiceService
{
    private readonly IInvoiceStore _store;

    // The service is handed what it needs instead of creating it.
    public InvoiceService(IInvoiceStore store)
    {
        _store = store;
    }

    public void MarkPaid(string reference, DateOnly paidOn)
    {
        Invoice? invoice = _store.FindByReference(reference);

        if (invoice is null)
        {
            throw new InvalidOperationException($"No invoice found with reference {reference}.");
        }

        invoice.MarkPaid(paidOn);
        _store.Save(invoice);
    }
}
  • Interface members have no bodies and no access modifiers. They are public by definition — an interface is entirely a public contract.
  • public class SqlInvoiceStore : IInvoiceStore means this class implements the contract. If it misses a member, it does not compile.
  • The question mark in Invoice? says the method may return nothing. Nullable reference types get their own lesson later; for now read it as "this might be null, so check it".
  • InvoiceService holds IInvoiceStore, not SqlInvoiceStore. It has no idea whether storage is a database, a file or a list in memory, and cannot find out.
  • The constructor receiving the store rather than writing new SqlInvoiceStore() inside is the whole trick. A class that receives what it needs instead of creating it is said to use dependency injection — the term is grander than the idea.

Now consider testing MarkPaid. There are three behaviours worth checking: a known reference gets marked paid and saved, an unknown reference throws, and an already-paid invoice is handled however you decided it should be.

If InvoiceService created its own SqlInvoiceStore, every one of those tests needs a database. You would need a server running, a schema, seed rows in a known state, and cleanup afterwards so the next test is not affected by this one. The tests would take seconds each, would fail on a laptop with no database, and would fail for reasons that have nothing to do with MarkPaid.

Because the service depends on an interface, you can hand it a different implementation.

A second implementation that exists only for tests
C#
public class InMemoryInvoiceStore : IInvoiceStore
{
    private readonly List<Invoice> _invoices;

    public int SaveCount { get; private set; }

    public InMemoryInvoiceStore(List<Invoice> seed)
    {
        _invoices = new List<Invoice>(seed);
    }

    public Invoice? FindByReference(string reference)
    {
        foreach (Invoice invoice in _invoices)
        {
            if (invoice.Reference == reference)
            {
                return invoice;
            }
        }

        return null;
    }

    public void Save(Invoice invoice)
    {
        SaveCount++;

        if (!_invoices.Contains(invoice))
        {
            _invoices.Add(invoice);
        }
    }
}

// In a test: no database, no configuration, no cleanup.
Invoice invoice = new Invoice("INV-2201", 15_000m);
InMemoryInvoiceStore store = new InMemoryInvoiceStore(new List<Invoice> { invoice });
InvoiceService service = new InvoiceService(store);

service.MarkPaid("INV-2201", new DateOnly(2026, 3, 14));

Console.WriteLine(invoice.IsPaid);    // True
Console.WriteLine(store.SaveCount);   // 1
  • This class satisfies the same contract with a list instead of a database. InvoiceService cannot tell the difference, because the interface is all it ever sees.
  • new List<Invoice>(seed) copies the items into a new list, so the caller's list is not modified by later Save calls.
  • SaveCount is not part of IInvoiceStore. It exists so a test can check that the service actually saved, which is behaviour worth asserting and would otherwise be invisible.
  • The test runs in well under a millisecond, needs nothing installed, and fails only if MarkPaid is wrong. Those three properties are what make a test suite worth keeping.
  • A stand-in like this is usually called a fake. Mocking libraries generate similar objects for you, and hand-written fakes remain a perfectly good option, particularly for interfaces you use in many tests.

Interface or abstract class, put side by side:

 InterfaceAbstract class
Contains implementationNo, by designYes, that is the reason to use one
Holds stateNoYes
How many per typeSeveralOne
Works across unrelated typesYesNo, they must share a base
Sentence it expressesThis type can do XThis type is a kind of X
Effect of adding a memberBreaks every implementerHarmless if you give it a body

Summary

  • An interface lists what a type can do and contains no implementation
  • A class depending on an interface rather than a concrete type can be handed a different implementation — that is dependency injection
  • The practical payoff is testing logic without the database, network or clock it normally talks to
  • Use interfaces at boundaries; a single-implementation interface over internal logic is usually ceremony
  • The costs are harder navigation and more to keep in step, and every added member breaks all implementers

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write an INotificationSender interface with one method, Send(string recipient, string subject, string body). Give it a RecordingNotificationSender implementation that keeps every message in a list instead of sending anything.

Then extend InvoiceService so marking an invoice paid also sends a confirmation, and check in plain code that exactly one message was recorded.

Show solution

The service now takes two dependencies through its constructor, and neither has to be real for you to check the behaviour. The recording sender is also more useful than a silent stub, because it lets you assert what was sent, not only that something was.

Notice what you did not have to do: no mail server, no network, no risk of a test sending an actual email to a customer. That last one is not hypothetical, and it is a good reason to keep senders behind an interface even when there will only ever be one real implementation.

C#
public interface INotificationSender
{
    void Send(string recipient, string subject, string body);
}

public class RecordingNotificationSender : INotificationSender
{
    public List<string> Sent { get; } = new List<string>();

    public void Send(string recipient, string subject, string body)
    {
        Sent.Add($"{recipient}: {subject}");
    }
}

public class InvoiceService
{
    private readonly IInvoiceStore _store;
    private readonly INotificationSender _notifications;

    public InvoiceService(IInvoiceStore store, INotificationSender notifications)
    {
        _store = store;
        _notifications = notifications;
    }

    public void MarkPaid(string reference, DateOnly paidOn)
    {
        Invoice? invoice = _store.FindByReference(reference);

        if (invoice is null)
        {
            throw new InvalidOperationException($"No invoice found with reference {reference}.");
        }

        invoice.MarkPaid(paidOn);
        _store.Save(invoice);

        _notifications.Send(invoice.CustomerEmail, $"Payment received for {reference}", "Thank you.");
    }
}

Think about it

Think about it

A colleague has put an interface in front of every class in the project, including a TaxCalculator whose only method multiplies an amount by a rate.

Which of those interfaces are doing work, and how would you explain the difference without sounding like you are quoting a rule?

Show solution

The useful test is whether anything is ever substituted for the real thing — in production or in a test. An interface over a database store is substituted constantly. An interface over arithmetic never is, because the real calculator is already fast, deterministic and trivial to construct.

So the explanation is about what the interface buys, not about counting implementations. An interface is a seam: a place you can cut the program apart. You want seams where the two sides differ in cost or reliability — anything touching the network, the disk, the clock or another team's system. Inside a calculation there is nothing to cut apart.

There is a reasonable counter-argument worth acknowledging: some teams add interfaces everywhere so that dependency registration and mocking are uniform and nobody has to make a judgement call per class. That consistency has value. It is a trade against the extra files and the harder navigation, and it is a team decision rather than a correctness question.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

InvoiceService takes an IInvoiceStore in its constructor rather than creating a SqlInvoiceStore itself. What does that primarily make possible?

Saved in this browser only.