Association as the general structural link between objects: multiplicity, navigability, C# and EF Core representation, and pitfalls.
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.
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.
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).
Order.CustomerId (or Order.Customer), but Customer doesn't know its orders.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.
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.
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);
.WithMany() without a property.Enrolment with EnrolledOn).Include, projections with Select (best for reads), lazy loading (opt-in, risky).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).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.Customer.Orders with lazy loading turns customer.Orders.Count into loading thousands of rows; N+1 queries when iterating.Equals, GetHashCode or ToString — a StackOverflowException.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.
Checks modelling vocabulary and practical consequences in .NET code and EF Core.
| 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.
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).
Cascade, Restrict, SetNull, ClientSetNull).order.CustomerId = id).Include loads related data eagerly; multiple collection includes can cause cartesian explosion — use AsSplitQuery().Select(c => new { c.Name, OrderCount = c.Orders.Count() })) translate to efficient SQL and avoid loading entities at all.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.
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).
Customer and Order plain classes with bidirectional navigations and write a test showing inconsistency when only one side is set.System.Text.Json and see the cycle exception.Include and compare the SQL.// 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());
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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].
Last updated 30 Oct 2026.