Aggregation versus composition: whole/part relationships with shared or owned parts, and the consequences for lifetimes, deletes and persistence.
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.
Both are "has-a" relationships. The difference is ownership and lifetime: with composition, the whole owns its parts β they can't exist without it and are deleted with it. With aggregation, the whole uses parts that have independent lifetimes and may be shared. The distinction drives deletion rules, cascade configuration and how you write the C# and EF Core model.
| Whole | Part | Relationship | Why |
|---|---|---|---|
Order |
OrderLine |
Composition | A line means nothing without its order; delete the order β delete lines |
Order |
ShippingAddress (value object) |
Composition | The address snapshot belongs to the order |
Invoice |
InvoiceLine |
Composition | Same as order lines |
Team |
Player |
Aggregation | A player exists independently and can move teams |
Course |
Student |
Aggregation (many-to-many) | Students exist without courses and attend many |
Playlist |
Song |
Aggregation | Songs are shared by many playlists |
Department |
Employee |
Aggregation | Deleting a department shouldn't delete employees |
order.AddLine(...)).DeleteBehavior.Cascade. Removing a line from the collection should delete it (an "orphan").Restrict/NoAction (block deletion while referenced) or SetNull (detach). For many-to-many, only the join rows are deleted.Getting this wrong is expensive: cascade on an aggregation (deleting a department deletes its employees), or no cascade on a composition (orphaned OrderLine rows, FK errors).
// Composition: owner creates and controls the parts
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public ShippingAddress Address { get; private set; }
public void AddLine(ProductId product, int qty, Money price) => _lines.Add(new OrderLine(product, qty, price));
public void RemoveLine(OrderLineId id) => _lines.RemoveAll(l => l.Id == id);
}
// Aggregation: whole references independent objects (often by ID across aggregates)
public sealed class Team
{
private readonly HashSet<PlayerId> _players = [];
public IReadOnlySet<PlayerId> Players => _players;
public void Sign(PlayerId player) => _players.Add(player);
public void Release(PlayerId player) => _players.Remove(player); // player still exists
}
Signals of composition in code: the part's constructor is internal/called only by the owner, parts are private collections, and they're value objects or child entities without their own repository. Aggregation: parts have their own repository/lifecycle and are referenced by ID or shared reference.
// Composition: child entities with cascade, or owned/complex types
modelBuilder.Entity<Order>().HasMany(o => o.Lines).WithOne().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Order>().ComplexProperty(o => o.Address); // EF Core 8: value object columns in Orders table
// or OwnsOne(o => o.Address) / OwnsMany(o => o.Lines) for owned entity types
// Aggregation: independent entities, no cascade
modelBuilder.Entity<Employee>()
.HasOne<Department>().WithMany().HasForeignKey(e => e.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Course>().HasMany(c => c.Students).WithMany(s => s.Courses); // join rows deleted only
Owned types (OwnsOne/OwnsMany) and complex types (EF Core 8) express composition directly: the part has no independent identity in the model and is always loaded and saved with its owner. EF Core also deletes orphans of required relationships when you remove a child from a tracked collection.
An HR system mapped Department β Employees with EF Core's default cascade for a required relationship. A manager deleted an empty-looking test department in production that actually had employees assigned by an import β and cascade deleted 140 employee records, along with their leave balances. We restored from point-in-time backup, changed the relationship to Restrict with soft-deletes for departments, and added a review step for every OnDelete(Cascade) in migrations. The bug was modelling an aggregation as a composition.
Checks precise modelling knowledge with real persistence consequences.
In practice, the useful question is: "If I delete the whole, should the parts be deleted?" and "Can the part be referenced by anything else?"
In domain-driven design, an aggregate is a cluster of objects treated as one unit for changes and consistency, with a single aggregate root (e.g. Order with its OrderLines). Internally that's composition; between aggregates, references are by ID, which is closer to UML aggregation/association. The naming collision causes confusion in interviews β worth clarifying.
Rules of thumb for aggregate design (Vaughn Vernon): keep aggregates small, reference other aggregates by ID, modify one aggregate per transaction, and use domain events for eventual consistency across aggregates.
DeleteBehavior |
Database FK action | Tracked dependents | Use for |
|---|---|---|---|
Cascade |
ON DELETE CASCADE | Deleted | Composition (owned children) |
Restrict |
NO ACTION (fails if referenced) | Error if loaded | Aggregation where deletion must be blocked |
NoAction |
NO ACTION | Not changed by EF | Same, letting DB enforce |
SetNull |
ON DELETE SET NULL | FK set to null | Optional aggregation (detach) |
ClientSetNull |
NO ACTION | FK set to null in memory | Default for optional relationships |
ClientCascade |
NO ACTION | Deleted in memory only | When DB can't cascade (multiple paths) |
EF Core's defaults: required relationships default to Cascade; optional ones to ClientSetNull. So a non-nullable FK (e.g. int DepartmentId) silently gives you cascade β exactly the HR bug above. SQL Server also rejects multiple cascade paths, forcing explicit choices.
SaveChanges (configurable via DeleteOrphansTiming).OwnsOne, OwnsMany): no DbSet, can't be queried independently, always loaded with the owner, stored in the owner's table (OwnsOne) or a separate table (OwnsMany).For aggregated parts with history (employees, customers), soft deletes (IsDeleted + global query filter) avoid destructive cascades and preserve audit trails. Composition parts usually go with their owner whether soft or hard deleted. Note: privacy obligations (APP 11 β destroy or de-identify personal information no longer needed) may require real deletion eventually; plan retention jobs.
Lines and updates totals.onDelete: ReferentialAction.Cascade.DepartmentβEmployee with a non-nullable FK and inspect the generated migration's onDelete.Restrict, try deleting a department with employees, and observe the error.Order.Address as ComplexProperty and Order.Lines with OwnsMany, and compare tables.SaveChanges.// Composition: Order owns its lines
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public Guid Id { get; } = Guid.NewGuid();
public IReadOnlyList<OrderLine> Lines => _lines;
public void AddLine(string sku, int quantity, decimal unitPrice)
{
if (_lines.Sum(l => l.Quantity) + quantity > 100)
throw new InvalidOperationException("An order cannot exceed 100 items."); // rule across parts
_lines.Add(new OrderLine(sku, quantity, unitPrice)); // created by the whole
}
}
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
// Aggregation: Team groups independent players by ID
public sealed class Team(string name)
{
private readonly HashSet<Guid> _playerIds = [];
public string Name { get; } = name;
public IReadOnlyCollection<Guid> PlayerIds => _playerIds;
public void Sign(Player player) => _playerIds.Add(player.Id); // links an existing object
public void Release(Player player) => _playerIds.Remove(player.Id);
}
public sealed class Player(string name)
{
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; } = name;
}
// EF Core configuration showing the difference
public sealed class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().OwnsMany(o => o.Lines); // composition: no DbSet<OrderLine>, deleted with order
b.Entity<Employee>()
.HasOne<Department>().WithMany()
.HasForeignKey(e => e.DepartmentId)
.OnDelete(DeleteBehavior.Restrict); // aggregation: never cascade
}
}
public sealed class Department { public int Id { get; set; } public string Name { get; set; } = ""; }
public sealed class Employee { public int Id { get; set; } public int? DepartmentId { get; set; } }
What happens: cascade deletes configured from department to employees or customer to orders.
Why it is wrong: deleting the whole destroys independent data.
Do instead: use Restrict/SetNull or soft deletes for parts with independent lifetimes.
What happens: required FKs default to cascade without anyone deciding.
Why it is wrong: destructive behaviour by accident.
Do instead: configure OnDelete explicitly for every relationship and review migrations.
What happens: OrderLineRepository allows editing lines without the order.
Why it is wrong: bypasses the owner's invariants (totals, status rules).
Do instead: modify parts only through the owner.
What happens: two orders reference the same Address entity; updating one changes the other.
Why it is wrong: historical data (where an order was shipped) changes retroactively.
Do instead: copy value objects into each owner (snapshot), modelled as complex/owned types.
What happens: composition modelled without cascade; removed children stay in the database. Why it is wrong: orphan rows, FK errors and inconsistent totals. Do instead: cascade deletes and orphan removal for owned children.
What happens: "aggregate" used ambiguously in design discussions. Why it is wrong: leads to inconsistent modelling decisions. Do instead: clarify: DDD aggregates are consistency boundaries (composition inside, ID references outside).
What happens: Include(t => t.Players) everywhere for teams with large rosters.
Why it is wrong: loads independent objects the use case doesn't need.
Do instead: reference by ID and query as needed.
What happens: deleting employees or customers referenced by historical records. Why it is wrong: breaks audit trails and reports, or fails on FKs. Do instead: soft-delete with retention policies that respect privacy obligations.
Seniors use the distinction to set transaction and consistency boundaries, not just to draw diagrams.
| Concern | Composition | Aggregation |
|---|---|---|
| Transaction | whole and parts saved together | separate transactions |
| Invariants | enforced by the whole | enforced by each object or a process |
| Delete | cascade | restrict / reassign |
| Loading | always with the whole | on demand, by ID |
| DDD | inside one aggregate | between aggregates |
The senior point: wrong cascade rules are one of the few modelling errors that destroy data, so the aggregation versus composition decision should be explicit and reviewed.
Composition means a class has other objects as parts and delegates work to them, instead of inheriting behaviour. A Car has an Engine and Wheels; an OrderProcessor has a validator, a pricer and a notifier. In the strict UML sense, composition also means ownership: the parts are created and destroyed with the whole and are not shared. Benefits: behaviour can be swapped (pass a different IPricingStrategy), combined freely (decorators wrap each other), tested in isolation, and changed without a fragile base class. The cost is a little more wiring, usually handled by constructors or dependency injection.
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.
How to tell it (STAR)
**Situation:** At [company] deleting a department in our HR system silently deleted 37 employee records, and payroll failed the next day. **Task:** I led the recovery and the fix. **Action:** We restored the data from backup. The root cause was modelling: employees were mapped as composed parts of a department with cascade delete, but they are independent people who move between departments, which is aggregation. I changed the mapping to `Restrict`, added a rule that departments must be empty before deletion, and introduced soft delete. I also added a review step for every cascade rule. **Result:** No data was lost permanently, and in [the following year] no cascade-related incidents happened; the review step caught [two] other risky cascades.
Last updated 30 Oct 2026.