# Who Owns a Lookup Value in a Modular Monolith?

A lookup table looks harmless until three modules start depending on it for different reasons. Suppose an underwriting system has a list of countries. Submission intake uses it to normalise addresses. Pricing uses it to select a region. Reporting uses it to group results. An admin screen lets someone edit the list. Before long, the `Countries` table sits in a shared project, every module reads it directly, and nobody can say who is allowed to change what a country means. The problem is not that multiple modules read the same value. The problem is that a shared row can quietly become a shared business rule.

## Start with the meaning of the value

The word *lookup* tells us how the UI presents data, not who owns it. I find it useful to ask what a change to the value would affect. A country code such as `IE` is a reference identifier used by several modules. A pricing region assigned to `IE` is a pricing decision. A rule saying that a submission from `IE` requires additional review belongs to whichever module owns that review decision. Putting all three in one `Country` entity makes unrelated modules depend on the same schema and release schedule. The same distinction applies to other familiar lists. A currency code is reference data; the currencies an insurer accepts for a particular product are product rules. A reason code may be a shared identifier; whether that reason is available for a given workflow state is a workflow rule. A user's role name may be a label; permission to perform an operation belongs in authorisation logic.

Ownership follows the rule that gives the value its meaning. Ask who can approve a change, who needs to validate it, and whose tests should fail if it changes. If those answers point to different teams or modules for different columns, the table is probably combining more than one concept.

## One owner does not mean one reader

In a modular monolith, all modules may use the same database and deploy as one application. You can still assign a single module responsibility for maintaining a dataset. Other modules can consume it through a small contract without acquiring the right to mutate its tables or interpret its internal columns. For example, a `ReferenceData` module could own ISO-style country identifiers and display names. `Underwriting` could own the set of allowed territories for a programme. `Pricing` could own its territory to region mapping. These boundaries are conceptual, they dont require separate databases, an event bus or a project rename. A consumer contract can be as small as this:

```csharp
public sealed record CountryReference(
    string Code,
    string DisplayName,
    bool IsActive);

public interface ICountryReferences
{
    Task<CountryReference?> FindAsync(
        string code,
        CancellationToken stopToken);
}
```

The reference module implements the interface. A submission intake handler asks it to resolve a code, then applies its own rule about what to do with an inactive or unknown country. The contract supplies facts; it does not decide whether the submission is acceptable.

```csharp
public sealed class CreateSubmissionHandler(
    ICountryReferences countries,
    IProgrammeTerritories territories)
{
    public async Task<CreateSubmissionResult> HandleAsync(
        CreateSubmission command,
        CancellationToken stopToken)
    {
        var country = await countries.FindAsync(
            command.CountryCode,
            stopToken);

        if (country is null)
        {
            return CreateSubmissionResult.UnknownCountry();
        }

        var isAllowed = await territories.IsAllowedAsync(
            command.ProgrammeId,
            country.Code,
            stopToken);

        if (!isAllowed)
        {
            return CreateSubmissionResult.TerritoryNotAllowed();
        }

        return CreateSubmissionResult.Accept(country.Code);
    }
}
```

`IProgrammeTerritories` is an underwriting contract, implemented by the module that owns programme eligibility. The example deliberately keeps country existence and programme eligibility separate. A country may be a valid reference value and still be unavailable for a particular programme. Whether `IsActive` affects intake is another explicit decision for the consuming workflow; it should not be smuggled into the reference provider as a universal rule. The interface isnt magic. If every consumer needs a different query, and the reference module gains dozens of methods tailored to unrelated workflows, the boundary has become a remote control for its database. That is a signal to examine the data and the responsibilities again.

## A shared database gives you a choice

There are several reasonable ways for modules to consume owned reference data inside one process. A narrow query interface is often the simplest. It gives the owner control over what it promises and lets consumers avoid a dependency on its EF Core entities. Direct read access to a stable table or view can also be pragmatic, particularly for reporting or existing code with a lot of joins. If you take that route, document the table or view as a read contract, restrict writes to the owner, and accept that schema changes will require coordinating consumers. A database view can present a stable projection while the owner's internal tables evolve.

Copying a small dataset into each module can make sense when consumers need independent availability or a distinct historical interpretation. It introduces a synchronisation problem, so make the source, refresh mechanism and expected lag explicit. Duplicating a list simply to make the diagram look more modular usually increases work without clarifying ownership. Even separate EF Core `DbContext` classes do not enforce ownership on their own. A module can still map another module's tables and start depending on internal columns. EF Core supports schema mapping, but a schema name is a database organisation tool, not a substitute for an agreed contract. The useful boundary is the one developers can see in code review and maintain when a field changes.

## Values change at different speeds

The next complication is time. Some reference values are effectively stable identifiers. Other lists are business configuration disguised as reference data. A programme's allowed territories might change next month while existing submissions must retain the rule that applied when they were accepted. A live lookup can answer "What is allowed now?" It cannot reliably answer "Why was this accepted in March?" if its rows have since changed.

For decisions that need to be explained later, record the resolved value and the rule version used at the time of the decision. Depending on the domain, that may mean storing an immutable decision snapshot, effective dated rules, or both. For example:

```csharp
public sealed record TerritoryDecision(
    string CountryCode,
    Guid ProgrammeId,
    bool WasAllowed,
    string RuleVersion,
    DateTimeOffset EvaluatedAt);
```

This record is not a second master list of countries. It is evidence of a particular underwriting decision. Re-running today's lookup against an old submission would answer a different question. The same principle applies when a lookup is supplied by an external system. Keep the external identifier where you need to reconcile it, but translate it at the boundary into a value with meaning in your application. If the provider renames a description, your historical decisions should not silently change with it.

## Who can edit it?

The admin screen is often where ownership becomes visible. If the `Lookups` module exposes a generic "edit any code and description" endpoint, it may let someone change a value that pricing or authorisation treats as a rule. Instead, put a command behind the module that owns each change. That module can validate dependencies, effective dates and existing usage before saving. For a genuinely shared reference list, its owner can provide a maintenance workflow. For a programme specific choice, the programme module should own the command even if its UI uses a shared country picker. The picker provides candidate identifiers; it does not own the rule that selects among them.

An active flag deserves particular care. Deactivating a code might remove it from new selections, but existing records must usually remain readable. If one module interprets deactivation as "hide in the picker" and another interprets it as "reject every existing record", the flag is carrying two policies. Name those policies separately.

## When I would leave the shared table alone

A modular monolith does not need a new module for every short list. If a table contains stable codes and names, has a clear maintainer, and consumers use it only to display or normalise values, a shared read contract may be enough. Moving the table, renaming its schema and rewriting every query would add risk without fixing a real coupling problem. I would start changing the design when a lookup begins to control decisions across modules, when several teams can edit the same rows, or when one module's schema change repeatedly breaks another. First identify the actual rule and its owner. Then extract only the consumer contract and decision logic that need a boundary. You can do that while leaving the physical table where it is.

The practical question is not "Which module owns the lookup table?" It is “Who owns each fact and each rule represented by that table?” Once those are explicit, the code, edit workflow and database access can reflect them without turning a small modular monolith into a distributed system.

Microsoft's [guidance on data ownership and bounded contexts](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/architect-microservice-container-applications/data-sovereignty-per-microservice) notes that a bounded context can be a logical boundary inside a monolithic deployment. The [EF Core entity mapping documentation](https://learn.microsoft.com/en-us/ef/core/modeling/entity-types) covers table and schema mapping. Neither dictates a module layout, the ownership choices above follow from the business meaning of the data.
