Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/two-repositories-one-winner && read --section="top" 0%
Architecture

Two Repositories, One Winner

Conferences registers in-memory and EF repositories for the same interfaces and lets last-registration-wins pick - a live lesson in DI as strategy selector, dead registrations as fossils, and a no-op UpdateAsync nobody deleted.

By Kumar Chandrachooda 12 Nov 2025 4 min read
Two candidate blocks offered to one socket, the losing one greyed out

Dependency injection containers keep everything you ever tell them. Registrations do not expire, do not warn when superseded, and do not distinguish “the implementation we use” from “the implementation we used in February”. Which makes a DI registration list a kind of sediment — and in the Conferences module of the DevMentors ModularMonolith estate, the sediment preserves an entire evolutionary stage. Part 9 read the data layer that won; this part reads the one that lost, and what the container's quiet arbitration between them teaches.

Four registrations, two interfaces

Here is Conferences.Core/Extensions.cs, the module's AddCore, exactly as shipped:

public static IServiceCollection AddCore(this IServiceCollection services)
{
    services.AddScoped<IConferenceService, ConferenceService>();
    services.AddScoped<IHostService, HostService>();
    services.AddSingleton<IHostRepository, InMemoryHostRepository>();
    services.AddSingleton<IConferenceRepository, InMemoryConferenceRepository>();
    services.AddDatabase();

    return services;
}

And AddDatabase, three lines further down the call chain in DAL/Extensions.cs:

public static IServiceCollection AddDatabase(this IServiceCollection services)
{
    services.AddPostgres<ConferencesDbContext>();
    services.AddScoped<IConferenceRepository, ConferenceDatabaseRepository>();
    services.AddScoped<IHostRepository, HostDatabaseRepository>();

    return services;
}

Both repository interfaces are registered twice — first as in-memory singletons, then as EF-backed scoped services. Microsoft's container resolves a single request for IConferenceRepository to the last registration, so the EF repositories win and the module works against Postgres. The in-memory pair is dead weight: constructed never (they are only instantiated if resolved), used never, removed never.

Dead, but not inert. Ask the container for IEnumerable<IConferenceRepository> and you get both implementations — the standard mechanism behind handler lists — so any future code that enumerates rather than resolves would suddenly consult a phantom empty repository. And the two registrations disagree about lifetime: singleton versus scoped, fossilised in one method, a mismatch that would matter instantly if the order ever flipped, because a singleton in-memory repository and a scoped EF repository have entirely different concurrency and state contracts.

The git layer under the sediment

The history explains how the layers formed. Commit 8e0963c (2021-01-27) built Hosts CRUD on the in-memory repository alone — the estate's walking skeleton, no database required. Three weeks later, ffd157d (2021-02-17) brought EF Core, Postgres, docker-compose and the *DatabaseRepository classes — and its version of AddCore is character-for-character the code above. The EF registrations were appended after the in-memory ones, the shadowing noted or unnoticed, and the file never changed again.

Read generously, this is the correct build order executing cleanly: domain logic first against a fake, real persistence second, the swap costing two lines. The repository interface plus DI is functioning as a Strategy pattern with the container as selectorConferenceService never learned the storage changed. That is the pattern working exactly as advertised, and it is a more genuine Strategy than the messaging ternary of part 8, because here the variants really are separate classes selected at composition time. Read strictly, the swap was never finished: replacing a registration by out-shouting it is not replacing it, and the module's composition root now documents a false architecture — a reader skimming AddCore reasonably concludes the module runs on in-memory singletons.

The losers, inspected

The in-memory repositories deserve their own reading, because they carry two honest confessions. First, a comment:

internal class InMemoryConferenceRepository : IConferenceRepository
{
    //Not thread-safe
    private readonly List<Conference> _conferences = new();

A List<T> mutated by a singleton serving concurrent web requests — the comment is accurate, and it is addressed to a reader rather than a maintainer: it flags the hazard and nobody ever fixed or removed it, the teaching-code idiom of marking a shortcut instead of closing it. Second, a method:

public Task UpdateAsync(Conference conference) => Task.CompletedTask;

An update that does nothing. It even works, accidentally: GetAsync hands back the live object from the list, callers mutate it in place, so by the time UpdateAsync runs there is nothing left to do. The no-op is load-bearing only because of a reference-semantics coincidence the interface never promised — copy the pattern in front of a database, or return clones from GetAsync, and every update silently vanishes. It is the smallest possible specimen of the estate's recurring disease: an interface satisfied is not a contract honoured. The type checker confirms UpdateAsync exists; only a test could confirm it updates. There are no tests.

Speakers, written after the EF migration, has none of this — one repository, EF-backed, registered once. The sediment is a Conferences-only stratum, which is itself evidence for part 13's fossil-record reading.

What a maintainer would do, and what the container should

The cleanup is a four-line deletion — remove the two singleton registrations, delete the two in-memory classes or park them in a test support folder (they are, after all, decent fakes; the estate's empty test project is the one place they would still earn their keep). The interesting question is why nothing forced the cleanup, and the answer is that DI containers are silent about shadowing by design: last-wins is a feature, used deliberately by frameworks to let user registrations override defaults. Silence in the framework means the discipline must live in review — or in a startup assertion. A five-line diagnostic that walks IServiceCollection and fails on duplicate service types you did not expect would have flagged this in February 2021; today you would reach for that, or for TryAddScoped semantics that make override-versus-accident explicit at the call site.

The distilled rule: registrations are code, and unreachable registrations are dead code — but no compiler will grey them out for you. Audit your container the way you audit your dependencies; both accumulate history that looks like architecture.

The repositories threw exceptions to signal missing conferences and duplicate speakers, and those exceptions have a second life: the shared middleware turns their class names into wire-format error codes. That envelope — Humanizer, a memoization cache, and a 400 that should sometimes be a 404 — is next.