DEV Community

Akintunde Morakinyo
Akintunde Morakinyo

Posted on

Validating References in NoSQL Databases: A Reusable C# Approach Using MongoDB

By Akintunde Morakinyo

I have encountered staff records still assigned to disabled departments. A new department had been created, but the department reference on the staff record had not been updated. I have also encountered records whose referenced documents had been deleted, yet the application continued to treat the references as valid.

These experiences raised two questions: how can I prevent a new record from accepting an unavailable reference, and how can I expose existing references that have become invalid?

Filtering dropdowns helps people using the frontend. It does not protect an integration that submits a code without knowing the target has been disabled or deleted. The backend needs its own check before accepting that write.

That motivated a reusable ValidateReference mechanism: models declare their reference requirements, and a shared validator checks them before persistence. It can validate new records and, where configured, revalidate existing records when they are updated.

A NoSQL reference is not automatically a foreign-key constraint

Consider this simplified document:

{
  "email": "developer@example.com",
  "departmentCode": "DEP-001"
}
Enter fullscreen mode Exit fullscreen mode

The application treats departmentCode as a relationship. Storing that value does not establish that a matching department exists.

In document-oriented NoSQL databases such as MongoDB, relationships can be represented through embedding or references. MongoDB's schema validation checks document structure and values, but does not provide relational foreign-key enforcement across collections. Its data-consistency guidance explains why applications must plan for consistency when related data is stored separately.

The first decision is still data modelling. If information belongs within one aggregate, embedding may make more sense. Shared records such as departments and staff often need independent identities and lifecycles, so references remain useful.

Why frontend filtering is not enough

Suppose the frontend hides disabled departments. A user loading a fresh list can select only an available department. That improves the interface, but other write paths still exist.

An integration may have downloaded its department list earlier. An import may contain codes copied from an old spreadsheet. Even a user can leave a form open while another user disables a department.

Those callers may submit a department code that is no longer usable. The backend should validate the reference against its current rules rather than assume that a caller has already filtered it correctly.

The same validation path must be invoked for integration and import writes. Adding an attribute does not protect a write path that bypasses the validator.

Why individual checks became repetitive

A service can check a department before saving a staff record. Another service can check a staff member before saving a leave request. That works, but every service must remember the same details:

  • Is the reference required?
  • What model and key does it point to?
  • Must the target satisfy a status or another business rule?
  • Which tenant or company is allowed to see it?
  • What happens when the request contains many records?

Repeating these decisions across services makes omissions more likely. I wanted the relationship requirements to be declared consistently and enforced through a shared path.

What is ObjectState?

ObjectState describes the operation intended for a record: New for creation, Changed for an update, and Removed for deletion. It lets the save pipeline apply validation according to the operation. For references, I validate New and Changed, but not Removed.

Declare the relationship, then validate it

The reference metadata identifies a target model, a target key and, where needed, additional rules. It supports a model type or a resolvable model name. Target-property names use nameof, such as nameof(Department.Code), instead of a literal "Code".

That gives refactoring tools a chance to catch renamed properties. It does not establish that the referenced record exists; the runtime check still does that work.

Here is a simplified illustration of the attribute declaration (adapt the constructor and options to your implementation):

public class Staff : DBModel
{
    [ValidateReference(typeof(Department), nameof(Department.Code),
        Required = true, States = new[] { ObjectState.New, ObjectState.Changed })]
    public string DepartmentCode { get; set; } = "";
}
Enter fullscreen mode Exit fullscreen mode

The declaration says that DepartmentCode must identify an existing Department by its Code. An additional active-status rule should be configured using the project's actual rule API, so disabled departments fail validation too. The attribute is metadata; it does not query MongoDB on its own.

The save pipeline applies that metadata before persistence:

// Simplified save-flow example; API names depend on the implementation.
await referenceValidator.ValidateAsync(staffRecords);
await repo.Save(staffRecords);
Enter fullscreen mode Exit fullscreen mode

This check must run through the same shared write path used by the UI, integrations and imports.

The additional rules are combined with AND. In this example, finding the code is insufficient: the matching department must also be active.

Why checking on update matters

In the staff-and-department case I encountered, creating a replacement department did not update staff still pointing to the disabled one. Validation only at creation would have missed the problem: the department could have been valid when the staff record was first saved.

Enabling validation for ObjectState.Changed would make the next staff update a checkpoint. Even if the user is editing a different field, the validator can reject the save because the existing department no longer satisfies the active-department rule. That makes the stale assignment visible and prompts correction before the update proceeds.

Here, checking on update means revalidating the configured reference when its containing record is updated. It must not be limited to cases where the department field itself changes, or this scenario would be missed.

The validator does not choose the replacement department or migrate staff automatically. It identifies the invalid assignment; the user or a separate reassignment process supplies the correct value. It also does not proactively scan untouched records, so a separate audit is needed to find every existing problem immediately.

This behaviour is intentional for a current operational record that must have an active department. Historical records need different treatment: a completed payroll record might legitimately preserve its original department after that department is disabled. The rule should follow the record's business meaning.

Validate a batch without one lookup per record

For each attributed property, the validator collects distinct populated values from the records being validated. It rejects missing required values, skips empty optional references, and looks up the remaining keys together with their rules.

Suppose 100 staff records refer to three departments. The logical lookup only needs those three distinct department codes. The validator compares the requested keys with the valid keys returned and identifies references that failed validation.

Conceptually:

For each configured reference property:
    Check required values
    Collect distinct populated keys
    Query permitted targets matching the keys and rules
    Compare requested keys with returned keys
    Report invalid references before saving
Enter fullscreen mode Exit fullscreen mode

The implementation groups this work per property rather than issuing a lookup for every input record. That is a query-shape improvement, not a measured performance claim. Large key sets may still need chunking, and lookup filters need appropriate indexes.

A correct batch lookup must also retrieve all matching keys. An ordinary paginated search that returns only the first page would incorrectly reject valid references beyond that page.

Scope is part of validity

In a multi-tenant application, a department can exist and still be invalid for the current request because it belongs to another tenant.

The validator uses the existing repository path, where the application's tenant and company scope is applied. The validator should not bypass that scope by issuing an unrestricted lookup.

This also means scope must follow each model's rules. A genuinely global reference model should not automatically receive a tenant filter simply because other models do.

Cache metadata, not assumptions about the database

One implementation detail mattered: the metadata cache scans concrete persisted DBModel types. It does not recursively validate every nested object.

I removed reference-validation attributes from nested models that are not independently persisted. Those objects have no standalone collection for this validator to query, and they are outside its traversal contract. Their business validation remains explicit in the relevant service—for example, nested appraisal KPI validation.

Caching the configuration avoids repeated discovery work. It should not be confused with permanently caching whether a department or staff record is valid; that underlying data can change.

Run the check before persistence

The save flow places reference validation before repo.Save(data), inside the application's transaction boundary where supported. Validation and persistence must actually share the database session for transaction semantics to apply to both.

Reference checks run for ObjectState.New and ObjectState.Changed. I exclude ObjectState.Removed: deleting a record should not require its outgoing references to remain valid.

That exclusion does not solve incoming dependencies. Preventing a department's deletion while staff still refer to it is a separate rule.

Deleted and disabled references need different explanations

A deleted target leaves a reference with no matching document. A disabled target still exists, but may no longer be eligible for a new assignment or a current operational record. Existence validation addresses the first case; an additional status rule addresses the second.

When I found deleted references still being treated as valid elsewhere, it showed why validating only the incoming field format was insufficient. On the next write through the validator, an existence check can expose the missing target. It does not restore that target or repair every dependent record.

Error messages should distinguish these cases where possible. For example, an illustrative message could be: "The selected department is disabled. Select an active department before saving." A missing target needs a different explanation. Integrations should receive a structured validation failure they can handle, rather than a successful save containing an unusable reference.

What this approach does not guarantee

A check-before-save is not a complete foreign-key implementation.

One operation can read a valid department while another deletes it. A transaction around the first operation does not automatically prevent every such race: MongoDB's transaction production considerations describe stale reads within transactions.

Stronger integrity requires a coordinated policy across writes and deletes. Depending on the model, that can include restricted deletion, soft deletion with clear eligibility rules, embedding, or transaction protocols that make competing operations conflict on the same document. Soft deletion by itself does not guarantee that a referenced target remains eligible.

Other boundaries matter too:

  • Imports, scripts and services that bypass the validator can create invalid references.
  • Historical references may need different status rules from new assignments.
  • A child referring to a parent created in the same incoming batch needs explicit save ordering or support for pending references.
  • Keys must use consistent matching semantics between database queries and in-memory comparisons.

These limitations define where the mechanism is useful and where additional design is needed.

The practical benefit

The useful contribution is a repeatable validation path: declare the reference, collect keys, apply scope and business rules, reject invalid data, then save.

It reduces scattered existence checks and makes relationship requirements easier to review. It also gives the application a clear place to improve error reporting and test cross-tenant references, optional fields, invalid statuses and batch behaviour.

Document-oriented NoSQL databases such as MongoDB leave important relationship decisions to the application. A reusable reference validator makes those decisions explicit, while keeping its guarantees honest.

Further reading

Top comments (0)