📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is association between classes in object-oriented design? Explain multiplicity, direction (unidirectional versus bidirectional), how associations appear in C# code and EF Core, and the problems bidirectional associations cause.

Association as the general structural link between objects: multiplicity, navigability, C# and EF Core representation, and pitfalls.

Mid-Level Theory Intermediate association ef-core object-relationships oop uml

🎯 Key points

  • Association is a stored, lasting link between objects with independent lifetimes
  • Aggregation and composition are stronger forms; dependency is a weaker, temporary use
  • Multiplicity describes how many objects are on each side: 1, 0..1, 0..*
  • Unidirectional associations are simpler; bidirectional ones must be kept in sync
  • Bidirectional graphs create serialisation cycles; use DTOs at API boundaries
  • Reference other aggregates by ID rather than object references
  • Many-to-many links with data deserve an explicit association class
  • EF Core maps associations to navigations and foreign keys; watch lazy loading and N+1

⏱ 30-second answer

Association is a lasting structural relationship where one object knows about another and can reach it, usually through a field or property: a Customer places Orders, a Teacher teaches Courses. It is the general relationship; aggregation and composition are stronger special cases that add ownership rules, and dependency is a weaker, temporary use. An association has multiplicity (one-to-one, one-to-many, many-to-many) and direction: unidirectional (Order.Customer only) or bidirectional (Customer.Orders and Order.Customer). In C# it appears as a reference property, a collection, or just an ID; in EF Core as navigation properties and foreign keys. Prefer unidirectional associations and IDs across aggregate boundaries; bidirectional links must be kept in sync and create cycles for serialisers.

🗣 Full answer (senior)

How I would answer this in an interview

An association is a structural relationship where objects of one class know about objects of another and that knowledge persists — typically held as a field or property (Order knows its Customer). I'd cover multiplicity, direction, how associations look in C# and EF Core, and why bidirectional associations cause trouble.

1. Multiplicity

Multiplicity says how many objects can be on each end:

Multiplicity Example C# shape
One-to-one (1..1) User ↔ UserProfile Reference property on one or both sides
Optional (0..1) Order → Voucher? Nullable reference
One-to-many (1..*) Customer → Orders Collection on the "one" side, reference on the "many" side
Many-to-many (..) Student ↔ Course Collections on both sides, or an explicit join entity (Enrolment)

Multiplicity is also a business rule: "an order must have exactly one customer" (required, non-nullable) vs "a customer may have no orders" (empty collection).

2. Direction

  • Unidirectional: only one side holds a reference. Order.CustomerId (or Order.Customer), but Customer doesn't know its orders.
  • Bidirectional: both sides hold references: Order.Customer and Customer.Orders. Navigating is convenient both ways, but both ends must be kept consistent.

Default to unidirectional, in the direction the use cases navigate. If you need "orders for a customer", that's often a query (db.Orders.Where(o => o.CustomerId == id)), not a navigation property you must load and maintain.

3. In C# code

public sealed class Order
{
    public OrderId Id { get; }
    public CustomerId CustomerId { get; }            // association by ID (another aggregate)
    private readonly List<OrderLine> _lines = [];    // composition: owned parts
    public IReadOnlyList<OrderLine> Lines => _lines;
}

Associations can be by object reference or by ID. In domain-driven design, associations between aggregates are by ID, to keep aggregates small, avoid loading huge graphs, and keep transactional boundaries clear.

4. In EF Core

EF Core calls them relationships, made of a principal, a dependent with a foreign key, and navigation properties:

modelBuilder.Entity<Order>()
    .HasOne(o => o.Customer)          // reference navigation
    .WithMany(c => c.Orders)          // collection navigation (omit for unidirectional: .WithMany())
    .HasForeignKey(o => o.CustomerId)
    .OnDelete(DeleteBehavior.Restrict);
  • Navigations can be on one side only (unidirectional) — EF Core is happy with .WithMany() without a property.
  • Many-to-many can be implicit (EF Core 5+ creates the join table) or explicit with a join entity carrying data (Enrolment with EnrolledOn).
  • Loading strategies: explicit Include, projections with Select (best for reads), lazy loading (opt-in, risky).

5. Problems with bidirectional associations

  • Consistency: setting order.Customer = c doesn't add the order to c.Orders in plain C#. Code must update both sides (EF Core's change tracker fixes them up for tracked entities, but plain objects and tests don't).
  • Serialisation cycles: Customer.Orders[0].Customer.Orders... — System.Text.Json throws (JsonException: A possible object cycle was detected) unless you use ReferenceHandler.IgnoreCycles — a symptom of serialising entities instead of DTOs.
  • Accidental huge loads: Customer.Orders with lazy loading turns customer.Orders.Count into loading thousands of rows; N+1 queries when iterating.
  • Tighter coupling: both classes depend on each other; changes ripple both ways and the classes can't be in different modules.
  • Equality/hash recursion: records with bidirectional references can recurse infinitely in generated Equals, GetHashCode or ToString — a StackOverflowException.

6. A production example

A CRM API returned Customer entities directly, with Customer.Orders and Order.Customer navigations and lazy loading enabled. One endpoint serialised a customer with ~20,000 orders: lazy loading fired thousands of queries, and serialisation hit a cycle error after enabling IgnoreCycles as a quick fix, the response was megabytes. We removed Customer.Orders (unidirectional Order → CustomerId), turned off lazy loading, and returned projected DTOs with paged orders. Response time went from seconds to tens of milliseconds.

🔍 What the interviewer is checking

Checks modelling vocabulary and practical consequences in .NET code and EF Core.

  • Green flags: defines association versus the other relationships, discusses multiplicity and direction, warns about bidirectional sync and cycles, uses IDs across aggregates and DTOs at boundaries.
  • Red flags: cannot distinguish association from composition, makes all links bidirectional, or returns entity graphs from APIs.

📚 Learn in depth

1. Association vs other relationships

Relationship Strength Lifetime coupling Typical C#
Dependency Weakest — uses temporarily None Method parameter, local variable
Association Knows about, persistent link Independent Field/property reference or ID
Aggregation Has-a, parts can be shared Parts can outlive whole Collection of references to independent objects
Composition Owns parts Parts die with whole Private collection of owned objects, owned types
Inheritance Is-a N/A : Base

Aggregation and composition are special kinds of association; the distinctions matter mainly for lifetime and deletion rules.

2. UML notation

Customer 1 -------- * Order          (association with multiplicity)
Order    * -------> 1 Customer       (navigable one way: unidirectional)
Order    ◆-------- * OrderLine       (composition)
Team     ◇-------- * Player          (aggregation)

Role names (e.g. placedBy) can label association ends, and the same two classes can have multiple associations (Order.BillingAddress, Order.ShippingAddress).

3. EF Core relationship mechanics

  • Foreign key properties are the source of truth in the database; navigations are object-level views.
  • The change tracker performs relationship fix-up: when you set a foreign key or add to a collection of tracked entities, EF Core updates the inverse navigation automatically. Outside tracking (new objects in tests, no-tracking queries with identity resolution off), you must manage both sides.
  • Required vs optional relationships map to nullable FKs and affect delete behaviour (Cascade, Restrict, SetNull, ClientSetNull).
  • Shadow FKs: EF can create FK columns without CLR properties, but explicit FK properties make updates without loading the principal easy (order.CustomerId = id).

4. Navigation properties and queries

  • Include loads related data eagerly; multiple collection includes can cause cartesian explosion — use AsSplitQuery().
  • Projections (Select(c => new { c.Name, OrderCount = c.Orders.Count() })) translate to efficient SQL and avoid loading entities at all.
  • Lazy loading proxies make every navigation access a potential query (N+1), often hidden in serialisation or loops.

5. Designing association direction

Ask: which use case navigates from A to B? If only one direction is needed, model only that. A collection navigation on the "one" side is often unnecessary when the use case can query by FK. Many teams keep a reference navigation on the dependent (Order.Customer) for queries and omit large collections like Customer.Orders, which can be unbounded.

6. Associations across module boundaries

In a modular monolith or microservices, associations across boundaries should be IDs only. The Ordering module stores CustomerId; it doesn't have a Customer entity from the CRM module. Data needed from the other module is fetched through its API or replicated via events (e.g. a local read model of customer names).

Try it yourself

  1. Create Customer and Order plain classes with bidirectional navigations and write a test showing inconsistency when only one side is set.
  2. Map the same model in EF Core and observe fix-up with tracked entities.
  3. Serialise a customer with orders using System.Text.Json and see the cycle exception.
  4. Rewrite an endpoint to use a projection instead of Include and compare the SQL.

💻 Code

// Unidirectional association by identifier (across aggregates)
public readonly record struct CustomerId(Guid Value);

public sealed class Order(CustomerId customerId)
{
    public CustomerId CustomerId { get; } = customerId;    // knows the customer, does not own it
}

// Bidirectional association kept in sync by one side
public sealed class Course(string code)
{
    private readonly List<Enrolment> _enrolments = [];
    public string Code { get; } = code;
    public IReadOnlyList<Enrolment> Enrolments => _enrolments;

    public Enrolment Enrol(Student student)
    {
        var e = new Enrolment(student, this);
        _enrolments.Add(e);
        student.AddEnrolment(e);           // keep the other end consistent
        return e;
    }
}

public sealed class Student(string name)
{
    private readonly List<Enrolment> _enrolments = [];
    public string Name { get; } = name;
    public IReadOnlyList<Enrolment> Enrolments => _enrolments;
    internal void AddEnrolment(Enrolment e) => _enrolments.Add(e);
}

// Many-to-many modelled as its own concept
public sealed class Enrolment(Student student, Course course)
{
    public Student Student { get; } = student;
    public Course Course { get; } = course;
    public string? Grade { get; set; }
}

// API boundary: DTOs break cycles
public sealed record StudentDto(string Name, IReadOnlyList<string> CourseCodes);
static StudentDto ToDto(Student s) =>
    new(s.Name, s.Enrolments.Select(e => e.Course.Code).ToList());

⚠ Common mistakes

1. Making every association bidirectional

What happens: both sides always get navigation properties. Why it is wrong: consistency burden, cycles, accidental large loads and tight coupling. Do instead: model the direction your use cases navigate; use queries for the other direction.

2. Updating only one side of a bidirectional association

What happens: order.Customer = c; without adding to c.Orders in non-tracked code. Why it is wrong: the object graph is inconsistent; logic using the other side sees stale data. Do instead: encapsulate changes in methods that update both sides, or remove one navigation.

3. Serialising entities with navigations

What happens: controllers return EF entities directly. Why it is wrong: cycles, over-exposure of data, lazy-load storms and huge payloads. Do instead: return DTOs or projections.

4. Unbounded collection navigations

What happens: Customer.Orders loaded for customers with thousands of orders. Why it is wrong: memory and performance blow-ups. Do instead: remove the navigation or query with paging.

5. Object references between aggregates

What happens: Order.Customer loaded and modified within order workflows. Why it is wrong: blurs transactional boundaries and invites modifying another aggregate in the same transaction. Do instead: reference other aggregates by ID.

6. Enabling lazy loading globally

What happens: lazy loading proxies used to avoid writing Include. Why it is wrong: hidden N+1 queries, especially in loops and serialisation. Do instead: load explicitly with projections or Include, and log queries in development.

7. Ignoring multiplicity rules

What happens: required associations modelled as nullable, or optional ones as required. Why it is wrong: the database and code accept invalid states or reject valid ones. Do instead: reflect business multiplicity in nullability and EF configuration.

8. Records with bidirectional references

What happens: two records reference each other. Why it is wrong: generated Equals, GetHashCode and ToString recurse infinitely. Do instead: use IDs for back-references or override the generated members.

🏛 Senior insight: trade-offs

Seniors treat associations as coupling decisions: every navigable link is a dependency that affects loading, consistency and serialisation.

Decision Senior default
Direction unidirectional unless both directions are needed
Across aggregates IDs, not object references
Many-to-many with data explicit association entity
API exposure DTOs, never entity graphs
EF Core loading explicit Include or projections, avoid lazy loading surprises

They also use the relationship vocabulary (association, aggregation, composition, dependency) to discuss lifetimes and cascades clearly with the team.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes a has-a relationship Why: Checks they can grade association into aggregation and composition
    How to answer

    Both are whole/part associations. In aggregation the parts exist independently and can be shared or move between wholes: a Team has Players, but a player exists without the team and can transfer. In composition the whole owns its parts exclusively; they are created with it and destroyed with it: an Order and its OrderLines. The practical consequences: composition implies cascade delete, parts are created inside the whole and not exposed for outside modification; aggregation implies no cascade, parts are referenced (often by ID) and managed elsewhere. In EF Core, composition maps naturally to owned types or required relationships with cascade delete; aggregation maps to optional or many-to-many relationships without cascade.

  2. When: Candidate says a class uses another Why: Separates a stored association from a transient dependency
    How to answer

    A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.

🛠 Real-world problems: how you handle them

Bidirectional Student and Course navigations produced huge API responses
Situation
A school management API exposed EF Core entities directly. `Student.Courses` and `Course.Students` were both navigable many-to-many collections.
Signals
Student endpoint responses of [about 10 MB], occasional `JsonException` for object cycles after an upgrade, and slow pages on the mobile app.
Investigation
Serialising a student walked every course, then every student in each course, then their courses. The team had set `ReferenceHandler.IgnoreCycles` as a workaround, which hid the cycles but not the size.
Resolution
Introduced an explicit `Enrolment` entity with grade and date, made API responses DTOs with only the fields each screen needs, and removed the reverse navigation where it was unused.
Prevention
Architecture rule that controllers return DTOs, response size budgets in API tests, and a modelling guideline to start with unidirectional associations.

How to tell it (STAR)

**Situation:** At [company] our student endpoint returned around 10 MB per call and sometimes threw cycle errors. **Task:** I was asked to make the API fast for the mobile app. **Action:** The entities had bidirectional many-to-many links and were serialised directly, so one student pulled in every course and every classmate. I modelled the relationship as an `Enrolment` entity, returned DTOs shaped for each screen, and removed the reverse navigation we did not need. I added tests with response size budgets. **Result:** Responses dropped to [a few KB], the cycle errors disappeared, and mobile page load time improved by [X seconds].

↩ Usually asked after

More in Core OOP

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me