Interfaces and Dependency Injection — The Foundation of Testable Code

Dependency Injection (DI) is the mechanism ASP.NET Core uses to supply objects with the dependencies they need. Instead of a service creating its own dependencies with new, they are passed in through the constructor — and the DI container creates and wires everything automatically. Interfaces are the glue that makes this work: the container maps an interface to a concrete implementation, and consuming code only ever sees the interface. When you swap implementations (real database vs in-memory test db, real email vs fake email), the consuming code never changes.

Interface + Implementation + Registration

// ── Step 1: Define the interface contract ────────────────────────────────
public interface IPostRepository
{
    Task<Post?>        GetByIdAsync(int id);
    Task<List<Post>>  GetAllAsync(int page = 1, int pageSize = 10);
    Task<Post>         CreateAsync(Post post);
    Task<Post>         UpdateAsync(Post post);
    Task               DeleteAsync(int id);
    Task<bool>         ExistsAsync(int id);
}

// ── Step 2: Write the concrete implementation ─────────────────────────────
public class PostRepository : IPostRepository
{
    private readonly AppDbContext _db;

    public PostRepository(AppDbContext db) => _db = db;

    public async Task<Post?> GetByIdAsync(int id)
        => await _db.Posts.FindAsync(id);

    public async Task<List<Post>> GetAllAsync(int page = 1, int pageSize = 10)
        => await _db.Posts
            .OrderByDescending(p => p.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

    public async Task<Post> CreateAsync(Post post)
    {
        _db.Posts.Add(post);
        await _db.SaveChangesAsync();
        return post;
    }

    public async Task<Post> UpdateAsync(Post post)
    {
        _db.Posts.Update(post);
        await _db.SaveChangesAsync();
        return post;
    }

    public async Task DeleteAsync(int id)
    {
        var post = await _db.Posts.FindAsync(id);
        if (post is not null)
        {
            _db.Posts.Remove(post);
            await _db.SaveChangesAsync();
        }
    }

    public async Task<bool> ExistsAsync(int id)
        => await _db.Posts.AnyAsync(p => p.Id == id);
}

// ── Step 3: Register in Program.cs ───────────────────────────────────────
builder.Services.AddScoped<IPostRepository, PostRepository>();
// IPostRepository (interface) → PostRepository (implementation)
// AddScoped = one instance per HTTP request
Note: ASP.NET Core’s built-in DI container has three service lifetimes: Scoped (one instance per HTTP request — use for database contexts and repositories), Transient (new instance every time it is requested — use for lightweight stateless services), and Singleton (one instance for the entire application lifetime — use for shared configuration, caches, and thread-safe utility services). Injecting a Scoped service into a Singleton causes a captive dependency bug — the Scoped service outlives its intended scope.
Tip: Register services in Program.cs using extension methods to keep the file readable. Create a static class like ServiceCollectionExtensions with a method AddApplicationServices(this IServiceCollection services) that contains all your service registrations. Then call builder.Services.AddApplicationServices() in Program.cs. This follows the same pattern used by ASP.NET Core itself — builder.Services.AddControllers(), builder.Services.AddDbContext() are all extension methods doing exactly this.
Warning: Never use new to create service dependencies inside a class — this bypasses DI, hardcodes the concrete type, and makes the class impossible to unit test without the real dependency. private readonly IPostRepository _repo = new PostRepository(db); is the anti-pattern. The correct pattern always receives dependencies through the constructor: public PostService(IPostRepository repo) { _repo = repo; }. The DI container injects the right implementation automatically.

Constructor Injection Pattern

// ── Service depends only on the interface, not the implementation ─────────
public class PostService
{
    private readonly IPostRepository    _repo;
    private readonly IEmailSender       _emailSender;
    private readonly ILogger<PostService> _logger;

    // DI container calls this constructor automatically
    public PostService(
        IPostRepository       repo,
        IEmailSender          emailSender,
        ILogger<PostService>  logger)
    {
        _repo        = repo;
        _emailSender = emailSender;
        _logger      = logger;
    }

    public async Task<Post> PublishAsync(int postId, string authorEmail)
    {
        var post = await _repo.GetByIdAsync(postId)
            ?? throw new KeyNotFoundException($"Post {postId} not found.");

        post.IsPublished = true;
        post.PublishedAt = DateTime.UtcNow;
        await _repo.UpdateAsync(post);

        _logger.LogInformation("Post {PostId} published by {Author}", postId, authorEmail);

        await _emailSender.SendAsync(
            authorEmail,
            "Your post is live!",
            $"'{post.Title}' is now published.");

        return post;
    }
}

// Register PostService itself
builder.Services.AddScoped<IPostService, PostService>();

// Testing is trivial — inject mocks
// var service = new PostService(mockRepo, mockEmail, mockLogger);

Common Mistakes

Mistake 1 — Using new inside a service (bypasses DI)

❌ Wrong:

public class PostService
{
    private readonly PostRepository _repo = new PostRepository(???);  // impossible to test!

✅ Correct — always receive dependencies through the constructor.

Mistake 2 — Injecting a Scoped service into a Singleton

❌ Wrong — the Scoped service lives as long as the Singleton (entire app lifetime), leaking state between requests.

✅ Correct — inject IServiceScopeFactory into the Singleton and create a scope explicitly when you need a Scoped service.

🧠 Test Yourself

Why does registering AddScoped<IPostRepository, PostRepository>() make PostService independently testable?