Working with ddd aggregates and Doctrine: Don’t despair over partial collections

As DDD practitioner you encounter significant problems working with Doctrine ORM. In this article we explore a common complaint: Doctrine doesn’t allow you to work with aggregates that include some, but not all, items of a one-to-many association. In other words, if you want to use Doctrine’s association management, you always have to include the entire entity collection for an association.

A common scenario: You want to build an appointment scheduling app. You have a calendar entity associated with many appointment entities. As is common with most scheduling apps, you want to work with a DaySchedule aggregate instead of single appointments to avoid double bookings: When a new appointment is scheduled through the DaySchedule, the aggregate ensures that the new appointment doesn’t conflict with any existing.

But Doctrine doesn’t allow you to check out a version of your calendar with only the appointments for the day loaded. All you can do is retrieve the entire calendar with all it’s associated appointments forever. Since a calendar for a busy institution having operated for years might include thousands of appointments, this unwieldy collection would be hard on memory and slow to work with 🥺

Alternatively, you could check out appointments individually — but then you don’t have an aggregate to work with anymore and would have to move invariants and validation to the application service level, thus leaving the calendar or schedule as an empty shell also known as anaemic model.

Before ditching Doctrine entirely (I feel you!), you might consider the following two options:

Solution 1: Use extra-lazy collections and DQL

This only works if you never ever run any appointment updates or removals through the aggregate: Doctrine loads your entire association collection as lazy proxies with only their primary keys (usually their IDs) eagerly loaded1. To avoid hydrating this collection, you can only perform limited operations on this query. Adding a new entity works without hydrating the entire collection. Searching conflicting appointments on the other hand does not.

If Doctrine allowed selecting more properties to load eagerly, we could select start and ends dates for skipping lazy initiation and wouldn’t ever have to hydrate any objects for checking conflicts. But it does not.

To avoid touching the entire, potentially huge collection, we can check out our own schedule for the day using Doctrine criteria DQL:

class Calendar {

    #[ORM\OneToMany(targetEntity: Appointment::class, mappedBy: 'calendar', cascade: ['persist', 'remove'], orphanRemoval: true)]
    private(set) ArrayCollection $appointments;

    //constructor and other methods...

    /**
    * @return Collection of appointments
    */
    private function daySchedule( DateTimeImmutable $day ): Collection {
        $criteria = Criteria::create();
        $dayStart = $day->setTime(0, 0);
        $dayEnd = $day->add( new DateInterval('P1D') );
        // Two timespans overlap if event.start <= input.end AND event.end >=     input.start

        $startBeforeInputEnd = Criteria::expr()->lte(
            'JSON_UNQUOTE(JSON_EXTRACT(timeSpan, "$.start"))',
            $dayEnd->format( 'Y-m-d H:i:s' )
        );
        $endAfterInputStart  = Criteria::expr()->gte(
            'JSON_UNQUOTE(JSON_EXTRACT(timeSpan, "$.end"))',
            $dayStart->format( 'Y-m-d H:i:s' )
        );

        return $this->events->matching( $criteria->where(
            Criteria::expr()->andX( $startBeforeInputEnd, $endAfterInputStart )
        ) );
    }

}

In this example I’m checking out all appointsments on a given day by querying their “timeSpan” property. TimeSpan is a value object denoting start and end of an appointment and it is saved here as json.

When scheduling a new appointment, we can use this collection to search for conflicting appointments. If we want to plainly refuse a new appointment whenever it conflicts with existing ones, that’s fine: adding a new appointment won’t hydrate the lazy collection. But if we only want to mark conflicts and let the user solve double bookings, we have to hydrate the entire extra-lazy collection of all appointments ever made to update the conflicting appointment entities 😦

Perhaps an even graver concern though is that we cannot perform optimistic locking on the aggregate. When we persist the calendar aggregate with all its appointments, Doctrine will only check if the calendar’s version has changed. It won’t know about changes to the appointments themselves though. An appointment could be updated without going through the Calendar aggregate, e.g. using domain events for eventual concurrency, and we wouldn’t know about it when we save, thus potentially creating double bookings (without marking them). But preventing those double bookings is an invariant and the very reason we are using an aggregate to begin with.

Solution 2: Using a non-managed DaySchedule aggregate

Instead of working with the entire calendar, we can create our DaySchedule aggregate from a collection of events:

class OrmScheduleRepository implements ScheduleRepository {

    public function daySchedule( CalendarID $calendarID, DateTimeImmutable $day ): Schedule {
        $dayStart = $day->setTime(0, 0);
        $dayEnd = $day->add( new DateInterval('P1D') );
        $appointments = $this->em->createQueryBuilder( 'e' )
                           ->select( 'e' )
                           ->where( 'e.ID = :ID' )
                           ->andWhere( 'JSON_UNQUOTE(JSON_EXTRACT(timeSpan, "$.start")) <= :end' )
                           ->andWhere( 'JSON_UNQUOTE(JSON_EXTRACT(timeSpan, "$.end")) >= :start' )
                           ->setParameter( 'ID', $calendarID )
                           ->setParameter( 'start', $dayStart )
                           ->setParameter( 'end', $dayEnd )
                           ->getQuery()
                           ->getResult();

        return new Schedule( ID: $calendarID, day: $timeSpan, appointments: ...$appointments );
    }

    public function upsert(Schedule $schedule): void {
        // Going to get interesting here later...
    }

}

Here we perform exactly the same query in our repository as we did with our DQL filter in solution #1 one. We now use the appointments found for a given date to create a DaySchedule aggregate on the fly, which is populated with the CalendarID, the date range it applies to, and all the appointments included as parameters.

class DaySchedule {

    private(set) array $appointments;

    public function construct(
        private(set) CalendarID $ID,
        private(set) TimeSpan $day,
        Appointment ...$appointments
    ) {
        $this->appointments = $appointments;
    }

    public function schedule(TimeSpan $timeSpan): void {
        // Check for conflicts with the day's existing appointments
        // Create a new appointment from the given $timeSpan
        // Add it to $this->appointments
    }

}

This aggregate can be used to check conflicts, add new appointments, and update existing ones without needing to access all appointments for the calendar. Kindly note that this aggregate is not and will never be saved and thus won’t be managed by Doctrine. In fact, Doctrine doesn’t know anything about it at all.

If we don’t persist the aggregate per se, we need to ensure that saving back the included appointments succeeds or fails in one transaction. Doctrine provides explicit transaction demarcation to wrap all our appointments in one transaction:

class OrmScheduleRepository implements ScheduleRepository {

    public function upsert( Schedule $daySchedule ): void {
        $this->em->beginTransaction();
        try {
            foreach($daySchedule->appointments as $appointment) {
                $this->em->persist( $appointment );
            }

            // Save all events
            $this->em->flush();
            $this->em->commit();
        } catch( \Exception $e ) {
            $this->em->rollback();
        }

}

If saving any of the included appointment fails — perhaps it’s been deleted in the meantime or the connection fails — Doctrine will roll back the entire transaction. In this case we would have to pull a new DaySchedule, perform our updates again, and try to save it once more.

If we want to manage concurrency, we still cannot do this on the aggregate level as in solution #1. But in contrast to the latter, we can now include optimistic locking for the individual appointments in our transaction. If any of them had been changed in the meantime, Doctrine would throw an OptimisticLockException and also roll back the entire transaction:

class OrmScheduleRepository implements ScheduleRepository {

    public function upsert( Schedule $daySchedule ): void {
        // Entities already managed by the ORM
        $updates = array_filter( $daySchedule->appointments, fn( Appointment $appointment ) => $this->em->contains( $appointment ) );
        // New entities
        $inserts = array_diff( $daySchedule->appointments, $updates );

        $this->em->beginTransaction();
        try {
            foreach( $updates as $appointment) {
                $this->em->lock( $appointment, LockMode::OPTIMISTIC, $event->version );
            }

            foreach( $inserts as $appointment ) {
                $this->em->persist( $appointment );
            }

            // Save all events
            $this->em->flush();
            $this->em->commit();
        } catch( OptimisticLockException ) {
            // Retry...?
            $this->em->rollback();
        } catch( \Exception $e ) {
            $this->em->rollback();
        }

}

If this solutions seems familiar, that’s because it’s almost identical to Julie Lerman’s famous vet clinic example from her DDD Fundamentals course. In contrast to her EFcore app, which does manage the DaySchedule through its active record system, our workaround with Doctrine has a couple of drawbacks:

  • If you want to manage deletions within the upsert method too, you need to implement some mechanism marking appointments for deletion. While Doctrine does mark entities for removal when you use removeElement() on an ArrayCollection comprising an entity’s to-many associations, and while you could also access entity states through the UnitOfWork, there is no way of letting Doctrine know that we intend to delete an appointment since we don’t have managed associations in our on-the-fly DaySchedule aggregate. Removing appointments from the aggregate has no impact whatsoever on the database.
  • If you need to persist schedule properties, you’re out of luck because we never save the aggregate. This could be the case if for instance users can select and save a strategy for dealing with appointment conflicts, hard fail or just marking. You could save this setting somewhere else entirely though and provide it as a parameter to the daySchedule() method in our repository.

Despite these issues, I still believe that solution #2 is a valuable approach when you must work with Doctrine or wish to avoid ditching it altogether. After all, using it with Symfony still reduces development time quite significantly. It would be amazing though, if the kind folks of the Doctrine team implemented partial association collections in the future.

To see how this works, take a look at example #3 for the lazy objects php documentation.

Would it be a bad idea to get in touch?

If you have a project in mind, need wise advice, or just want to say hi: Write on to { click/tap to reveal }

© 2026. Lazy Developer Studio. All rights reserved.