According to DDD principles, external objects should only call methods on an aggregate root (AR), not on other entities in the aggregate
Id rather say that an aggregate root is a consistency boundary. That's why "external objects should only call methods on the aggregate root".
On the other hand your value objects (VOs) or entities can be quite rich and encapsulate a lot of their internal rules.
E.g SeatNumber
cannot be negative, Seat
can have a method Book(Person person)
that makes sure that it's booked by only one person, Row
can have methods BookASeat(SeatNumber seatId, Person person)
and AddASeat(Seat seat)
, ...
public class Seat : Entity
{
private Person _person;
public Seat(SeatNumber id)
{
SeatId = id;
}
public SeatNumber SeatId { get; }
public void Book(Person person)
{
if(_person == person) return;
if (_person != null)
{
throw new InvalidOperationException($"Seat {SeatId} cannot be booked by {person}. {_person} already booked it.");
}
_person = person;
}
public bool IsBooked => _person != null;
}
I would create SeatingPlan.AddSeat(sectionId, rowId, seatNo)
in order to prevent external objects to call SeatingPlan.Sections[x].Rows[y].Seat[s].Add
, which is bad, right?
But still, the AddSeat
method of SeatingPlan
must delegate the seat creation to the row object, because the seat is a composite of the row, the row owns the seats. So it has to call Sections[x].Rows[y].AddSeat(seatNo)
.
It's not bad to call Sections[sectionNumber].Rows[rowNo].Seat[seat.SeatNo].Add(seat)
as long as Sections
is a private collection (dictionary) and SeatingPlan
doesn't expose it to an outside world.
IMHO: A disadvantage of this approach is the following - all domain rules are maintained by your aggregate root. It cam make you aggregate root too complex too understand or maintain.
In order to keep your aggregate simple I'd recommend to split into multiple entities and make each of them responsible for enforcing their own domain rules:
Row
is responsible for maintaining an internal list of its seats, has methods AddASeat(Seat seat)
and BookASeat(SeatNumber seatId, Person person)
Section
is responsible for maintaining an internal list of rows, knows how to add an entire valid row (AddARow(Row row)
) or just to add a seat to an existing row (AddASeat(RowNumber rowId, Seat seat)
)
Stadium
(or a seat plan) can have methods like AddASection(Section section)
, AddARow(Row row, SectionCode sectionCode)
, AddASeat(Seat seat, RowNumber rowNumber, SectionCode sectionCode)
. It all depends on the interface that you provide to your users.
You can describe your aggregate root without exposing internal collections:
/// <summary>
/// Stadium -> Sections -> Rows -> Seats
/// </summary>
public class Stadium : AggregateRoot
{
private readonly IDictionary<SectionCode, Section> _sections;
public static Stadium Create(StadiumCode id, Section[] sections)
{
return new Stadium(id, sections);
}
public override string Id { get; }
private Stadium(StadiumCode id, Section[] sections)
{
_sections = sections.ToDictionary(s => s.SectionId);
Id = id.ToString();
}
public void BookASeat(SeatNumber seat, RowNumber row, SectionCode section, Person person)
{
if (!_sections.ContainsKey(section))
{
throw new InvalidOperationException($"There is no Section {section} on a stadium {Id}.");
}
_sections[section].BookASeat(row, seat, person);
}
public void AddASeat(Seat seat, RowNumber rowNumber, SectionCode sectionCode)
{
_sections.TryGetValue(sectionCode, out var section);
if (section != null)
{
section.AddASeat(rowNumber, seat);
}
else
{
throw new InvalidOperationException();
}
}
public void AddARow(Row row, SectionCode sectionCode)
{
_sections.TryGetValue(sectionCode, out var section);
if (section != null)
{
section.AddARow(row);
}
else
{
throw new InvalidOperationException();
}
}
public void AddASection(Section section)
{
if (_sections.ContainsKey(section.SectionId))
{
throw new InvalidOperationException();
}
_sections.Add(section.SectionId, section);
}
}
public abstract class AggregateRoot
{
public abstract string Id { get; }
}
public class Entity { }
public class ValueObject { }
public class SeatNumber : ValueObject { }
public class RowNumber : ValueObject { }
public class SectionCode : ValueObject { }
public class Person : ValueObject { }
public class StadiumCode : ValueObject { }
public class Row : Entity
{
private readonly IDictionary<SeatNumber, Seat> _seats;
public Row(RowNumber rowId, Seat[] seats)
{
RowId = rowId;
_seats = seats.ToDictionary(s => s.SeatId);
}
public RowNumber RowId { get; }
public void BookASeat(SeatNumber seatId, Person person)
{
if (!_seats.ContainsKey(seatId))
{
throw new InvalidOperationException($"There is no Seat {seatId} in row {RowId}.");
}
_seats[seatId].Book(person);
}
public bool IsBooked(SeatNumber seatId) { throw new NotImplementedException(); }
public void AddASeat(Seat seat)
{
if (_seats.ContainsKey(seat.SeatId))
{
throw new InvalidOperationException();
}
_seats.Add(seat.SeatId, seat);
}
}
public class Section : Entity
{
private readonly IDictionary<RowNumber, Row> _rows;
public Section(SectionCode sectionId, Row[] rows)
{
SectionId = sectionId;
_rows = rows.ToDictionary(r => r.RowId);
}
public SectionCode SectionId { get; }
public void BookASeat(RowNumber rowId, SeatNumber seatId, Person person)
{
if (!_rows.ContainsKey(rowId))
{
throw new InvalidOperationException($"There is no Row {rowId} in section {SectionId}.");
}
_rows[rowId].BookASeat(seatId, person);
}
public void AddASeat(RowNumber rowId, Seat seat)
{
_rows.TryGetValue(rowId, out var row);
if (row != null)
{
row.AddASeat(seat);
}
else
{
throw new InvalidOperationException();
}
}
public void AddARow(Row row)
{
if (_rows.ContainsKey(row.RowId))
{
throw new InvalidOperationException();
}
_rows.Add(row.RowId, row);
}
}
how can I prevent external objects from calling Row.AddSeat
method, while allowing the aggregate root to call it ?
If you do not expose a Row
or Rows
as public property it automatically prevents others from calling it. E.g. in my example only Section
has access to its own private collection of _rows
and calls method AddSeat
on a single row
.
If you keep a state of the aggregate root private to itself it means that it can be changed through aggregate root methods only.