developer

I'm a PHP developer

Co-Organizer of Triangle PHP and Director of WWCode Raleigh/Durham

I work at a company that builds tools for network security threat assessment

I workED at a University

What we'll cover:

  • Processes from paper to web forms
  • What is Event Sourcing?
  • Introducing what this code looks like (LIGHT)
  • Advantages and Disadvantages

Brace Yourself!

Relax, Enjoy the Ride!

Sign the waiver

I, __(state your name)__, will not blame Emily if Event Sourcing makes my head explode. I promise I will not leave a bad review. And I promise to be kind to myself as I learn new things.

Emily will not be held responsible if your head explodes

Forms

A lot of our systems were built to replace paper processes

They often closely map to this physical form.

FormProcess

Paper Forms handling state

Paper forms sorted for a process

Piles indicate status of the form

Paper Forms handling state

stamped application

Status doesn't really communicate why or what happened

Why are they in the current state?

Compensating Measures: diagram of a table with a status column added and a drawing of a table with a related status table

How workflows become complex

a diagram of a request coming in to be reviewed and the admin sending it either to approved, rejected, or hold piles; but hold has another fork for reasons and hold can be for academic or financial reasons

A simple status drop-down

A "simple" status drop-down

Something Happened

Something happened

Status is a reflection of something that happened

There is ONE of each status + reasons/details

Events can record what happened

stamped application

The Request Doesn't Get Stuck

Enter Event Sourcing

Definition Event Sourcing

The fundamental idea of Event Sourcing is that of ensuring every change to the state of an application is captured in an event object, and that these event objects are themselves stored in the sequence they were applied for the same lifetime as the application state itself.

  • Martin Fowler

What's important about Events

  • Events
  • Details of the Event (attributes)
  • Order/Sequence

Rules for Events

  • RARELY changed
  • Never deleted
  • Events are usually named as past-tense verbs
  • Have attributes that are values
    • never an aggregate root
    • never a model/collection/object

The Object can change

Our Object definition changed

Events are not Bionic

The fact that an event happened doesn't ever change even though the behavior around it may have

Why Events?

state transistions are important

We need an audit log and proof of state

This history is more important than the current state

Able to replay these events to rebuild state

event_rules.jpg

Events don't change

The part of the code that will change is most likely the result that follows that event.

The structure of the resulting data is more likely to change than the behavior

The Code

There are many classes involved:

  • Events
  • Domain Message
  • Classes with Listeners
    • Projections
    • Read Models
  • Commands & Handlers (CQRS)
<?php

namespace App\Enrollment\Domain\Events;

use App\Enrollment\Domain\CourseOfferingId;
use App\Enrollment\Domain\StudentId;
use App\Support\Domain\Uuid;
use App\Support\EventHandling\Event;

class EnrollmentRequestRejected implements Event
{

    /**
     * @var Uuid
     */
    public $enrollmentRequestId;

    /**
     * @var StudentId
     */
    public $studentId;

    /**
     * @var CourseOfferingId
     */
    public $courseOfferingId;

    public function __construct(Uuid $enrollmentRequestId, StudentId $studentId, CourseOfferingId $courseOfferingId)
    {

        $this->enrollmentRequestId = $enrollmentRequestId;
        $this->studentId = $studentId;
        $this->courseOfferingId = $courseOfferingId;
    }

    /**
    * @return array
    */
    public function serialize()
    {
        return [
            'id' => $this->enrollmentRequestId->toString(),
            'student_id' => $this->studentId->toNative(),
            'course_offering_id' => $this->courseOfferingId->toNative(),
        ];
    }

    /**
    * @param array $data
    *
    * @return static
    */
    public static function deserialize($data)
    {
        return new StudentRequestedEnrollment(
            Uuid::fromString($data['id']),
            StudentId::fromNative($data['student_id']),
            CourseOfferingId::fromNative($data['course_offering_id'])
        );
    }
}
<?php

namespace App\Support\Domain;

use App\Support\EventHandling\Event;
use DateTime;

class DomainMessage
{

    /**
     * @var string
     */
    private $streamId;

    /**
     * @var int
     */
    private $version;

    /**
     * @var Event
     */
    private $payload;

    /**
     * @var DateTime
     */
    private $recordedOn;

    /**
     * @param          $streamId
     * @param int      $version
     * @param Event    $payload
     * @param DateTime $recordedOn
     *
     * @internal param string $id
     */
    public function __construct($streamId, $version, Event $payload, DateTime $recordedOn)
    {
        $this->streamId = (string) $streamId;
        $this->version = $version;
        $this->payload = $payload;
        $this->recordedOn = $recordedOn;
    }

    /**
    * @param       $streamId
    * @param int   $version
    * @param Event $payload
    *
    * @return DomainMessage
    * @internal param string $id
    */
    public static function recordNow($streamId, $version, Event $payload)
    {
        return new DomainMessage($streamId, $version, $payload, new DateTime());
    }

    /**
    * {@inheritDoc}
    */
    public function getStreamId()
    {
        return $this->streamId;
    }

    /**
    * {@inheritDoc}
    */
    public function getVersion()
    {
        return $this->version;
    }

    /**
    * {@inheritDoc}
    */
    public function getPayload()
    {
        return $this->payload;
    }

    /**
    * {@inheritDoc}
    */
    public function getRecordedOn()
    {
        return $this->recordedOn;
    }

    /**
    * {@inheritDoc}
    */
    public function getType()
    {
        return get_class($this->payload);
    }
}

Event Store

A domain specific database

A functional database based on a publish-subscribe messages pattern

Events Table - no Wrapper

Events Table - no Wrapper


O:92:"ITECS\Scholarships\Selection\Domain\Scholarship\AwardWasAssignedToCandidateByCommitteeMember":9:{
    s:6:"amount"    ;O:39:    "ITECS\Scholarships\Common\Values\Amount":1:{
        s:46:"ITECS\Scholarships\Common\Values\Amountmoney"        ;
        O:11:        "Money\Money":2:{
            s:19:"Money\Moneyamount"            ;i:285000            ;s:21:"Money\Moneycurrency"            ;
            O:14:            "Money\Currency":1:{
                s:20:"Money\Currencyname"                ;s:3:"USD"                ;
            }
        }
    }    s:11:"applicantId"    ;
    s:9:"222222235"    ;
    s:13:"scholarshipId"    ;
    O:61:    "ITECS\Scholarships\Selection\Domain\Scholarship\ScholarshipId":1:{
        s:76:"ITECS\Scholarships\Selection\Domain\Scholarship\ScholarshipIdscholarshipId"        ;
        i:360        ;
    }    s:17:"selectionCriteria"    ;O:59:    "ITECS\Scholarships\Selection\Domain\Scholarship\CriteriaSet":1:{
    s:69:"ITECS\Scholarships\Selection\Domain\Scholarship\CriteriaSetcriteria"        ;a:1:{
        i:0            ;O:93:            "ITECS\Scholarships\Selection\Domain\Scholarship\SelectionCriteria\MemberOfDepartmentCriterion":4:{
            s:106:"ITECS\Scholarships\Selection\Domain\Scholarship\SelectionCriteria\MemberOfDepartmentCriteriondepartments"                ;a:1:{
                i:0                    ;O:43:                    "ITECS\Scholarships\Common\Values\Department":2:{
                s:50:"ITECS\Scholarships\Support\Types\CodedValuelabel"                        ;s:17:"Civil Engineering"                        ;s:49:"ITECS\Scholarships\Support\Types\CodedValuecode"                        ;s:2:"CE"                        ;
                }
            }                s:99:"ITECS\Scholarships\Selection\Domain\Scholarship\SelectionCriteria\SelectionCriterionisRequirement"                ;b:1                ;s:101:"ITECS\Scholarships\Selection\Domain\Scholarship\SelectionCriteria\SelectionCriterionotherProperties"                ;a:3:{
                s:4:"type"                    ;s:10:"department"                    ;s:5:"value"                    ;a:1:{
                    i:0                        ;s:2:"CE"                        ;
                }                    s:8:"priority"                    ;i:1                    ;
            }                s:14:"*description"                ;N;
        }
    }
}    s:17:"committeeMemberId"    ;s:8:"elstamey"    ;s:4:"term"    ;s:18:"Both Fall & Spring"    ;s:19:"snapshottedCriteria"    ;a:1:{
        i:0        ;a:3:{
            s:8:"required"            ;b:1            ;s:7:"matched"            ;b:1            ;s:11:"description"            ;s:69:"Must be a student within one of these departments: Civil Engineering."            ;
        }
    }    s:13:"applicantName"    ;s:13:"Alana  Feeney"    ;s:9:"occuredOn"    ;O:8:    "DateTime":3:{
        s:4:"date"        ;s:26:"2015-12-07 14:26:12.000000"        ;s:13:"timezone_type"        ;i:3        ;s:8:"timezone"        ;s:16:"America/New_York"        ;
    }
}

Events Table - with Domain Message

Events Table - with Domain Message


{
    "namespace":"App\\StudentInformation\\Entities\\Student",
    "identity":"ophelia.zieme",
    "data":{
        "id":"ophelia.zieme",
        "ferpa":"0",
        "personal":{
            "first_name":"Evan",
            "middle_name":"Jalon",
            "last_name":"Armstrong",
            "dob":"2007-06-16",
            "gender":"M"
        },
        "contact":{
            "student_id":"000811740",
            "username":"ophelia.zieme",
            "email":"ophelia.zieme@ncsu.edu",
            "phone":"935-516-1228"
        },
        "address":{
            "physical":{
                "street1":"707 Nasir Pass",
                "street2":"Jett Loop",
                "street3":null,
                "street4":"",
                "city":"Clemmons",
                "state":"NC",
                "zip":"27012",
                "country":"USA"
            },
            "permanent":{
                "street1":"759 Bashirian Plains",
                "street2":null,
                "street3":null,
                "street4":"",
                "city":"Clemmons",
                "state":"NC",
                "zip":"27012",
                "country":"USA"
            }
        },
        "citizenship":{
            "nc_resident":true,
            "country":"USA",
            "visa":null
        }
    }
}

Projector

<?php

namespace App\Enrollment\ReadModel;


use App\Enrollment\Domain\Events\EnrollmentRequestRejected;
use App\Enrollment\Domain\Events\StudentRequestedEnrollment;
use App\Support\ReadModel\Replayable;
use App\Support\ReadModel\SimpleProjector;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Connection;

class EnrollmentRequestProjector extends SimpleProjector implements Replayable
{

    /**
     * @var Connection
     */
    private $connection;

    /**
     * @var string table we're playing events into
     */
    private $table = 'proj_enrollment_requests';

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    public function beforeReplay()
    {
        $builder = $this->connection->getSchemaBuilder();

        $builder->dropIfExists('proj_enrollment_requests_tmp');
        $builder->create('proj_enrollment_requests_tmp', function (Blueprint $schema) {
        $schema->string('id');
        $schema->string('student_id');
        $schema->string('course_offering_id');
        $schema->string('status');

        $schema->primary('id');
        });

        $this->table = 'proj_enrollment_requests_tmp';
    }

    public function afterReplay()
    {
        $builder = $this->connection->getSchemaBuilder();

        $builder->dropIfExists('proj_enrollment_requests');
        $builder->rename('proj_enrollment_requests_tmp', 'proj_enrollment_requests');

        $this->table = 'proj_enrollment_requests';
    }

    /**
    * @param StudentRequestedEnrollment $event
    */
    public function applyStudentRequestedEnrollment(StudentRequestedEnrollment $event)
    {
        $enrollmentRequest = new EnrollmentRequest();
        $enrollmentRequest->setTable($this->table);

        $enrollmentRequest->id = $event->enrollmentRequestId->toString();
        $enrollmentRequest->student_id = $event->studentId->toNative();
        $enrollmentRequest->course_offering_id = $event->courseOfferingId->toNative();
        $enrollmentRequest->status = 'requested';

        $enrollmentRequest->save();
    }

    /**
    * @param EnrollmentRequestRejected $event
    */
    public function applyEnrollmentRequestRejected(EnrollmentRequestRejected $event)
    {
        $enrollmentRequest = new EnrollmentRequest();
        $enrollmentRequest->setTable($this->table);

        $enrollmentRequest = EnrollmentRequest::where('id',  $event->enrollmentRequestId->toString());
        $enrollmentRequest->delete();
    }

}

Read Model

Read Model

<?php

namespace App\Enrollment\ReadModel;


use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

/**
* @codeCoverageIgnore
*/
class EnrollmentRequest extends Model
{
    protected  $table = 'proj_enrollment_requests';
    public $incrementing = false;
    public $timestamps = false;

    public static function current()
    {
        return static::where('student_id', auth()->user()->name)->get();
    }

    public static function lookupRequestsFor($username)
    {
        return static::where('student_id', $username)->get();
    }

    public function offering()
    {
        return $this->hasOne(CourseOffering::class, 'id', 'course_offering_id');
    }
}

Commands

<?php

namespace App\Enrollment\Commands;

use App\Support\CommandHandling\Command;
use App\Common\SemesterTerm;
use DateTime;
use Illuminate\Http\Request;

class ScheduleEnrollmentPeriod implements Command
{
    const DATE_FORMAT = DateTime::ATOM;

    /**
     * @var DateTime
     */
    public $open;

    /**
     * @var DateTime
     */
    public $close;

    /**
     * @var SemesterTerm
     */
    public $strm;

    public function __construct(DateTime $open, DateTime $close, SemesterTerm $strm)
    {
        $this->open = $open;
        $this->close = $close;
        $this->strm = $strm;
    }

    public static function fromRequest(Request $request)
    {
        return new static(
            static::parseDate($request->input('open'),'opened'),
            static::parseDate($request->input('close'),'closed'),
            SemesterTerm::fromNative($request->input('strm'))
        );
    }

    /**
    * The UI is presently sending a date 'Y-m-d' rather than a date-time, so attempt to parse as both if needed.
    *
    * @param $dateStr
    * @param $name
    * @return bool|DateTime
    */
    private static function parseDate($dateStr, $name)
    {
        // It should be noted that the time zone is presently set arbitrarily to EST all year around.
        // The standard HTML5 datetime-local (if we use it) will provide the local timezone selected.
        // The browser-supplied local timezone will possibly be different from the system timezone.
        // This will not be a problem right now, but may become confusing if open/close specify hours
        $dt = DateTime::createFromFormat(self::DATE_FORMAT, $dateStr."T00:00:00-05:00");

        if ($dt === FALSE) {
            $dt = DateTime::createFromFormat(self::DATE_FORMAT, $dateStr);
        }

        // This may be a bit defensive but createFromFormat does not throw exceptions
        // we don't expect bad dates to be passed in because it should be validated by the caller
        if ($dt === FALSE) {
            throw new \InvalidArgumentException(sprintf("%s date '%s' does not conform to the required format of '%s'",$name, $dateStr, self::DATE_FORMAT));
        }

        return $dt;
    }

    /**
    * @return array
    */
    public function serialize()
    {
        return [
            'open' => $this->open->format(self::DATE_FORMAT),
            'close' => $this->close->format(self::DATE_FORMAT),
            'strm' => $this->strm->toNative(),
        ];
    }

    /**
    * @param array $data
    *
    * @return static
    */
    public static function deserialize($data)
    {
        return new ScheduleEnrollmentPeriod(
            DateTime::createFromFormat(self::DATE_FORMAT, $data['open']),
            DateTime::createFromFormat(self::DATE_FORMAT, $data['close']),
            SemesterTerm::fromNative($data['strm'])
        );
    }
}

Handler

<?php

namespace app\Enrollment\Commands;

use App\Enrollment\Domain\EnrollmentPeriod;
use App\Enrollment\IdentityProvider;
use App\Support\CommandHandling\CommandHandler;
use App\Support\Repository\EventSourcingRepository;
use Illuminate\Auth\Access\AuthorizationException;

class EnrollmentPeriodHandler extends CommandHandler
{
    /**
     * @var IdentityProvider
     */
    private $auth;

    /**
     * @var EventSourcingRepository
     */
    private $repository;

    public function __construct(IdentityProvider $auth, EventSourcingRepository $repository)
    {
        $this->auth = $auth;
        $this->repository = $repository;
    }

    /**
    * @param ScheduleEnrollmentPeriod $command
    * @throws AuthorizationException
    */
    public function handleScheduleEnrollmentPeriod(ScheduleEnrollmentPeriod $command)
    {
        $this->auth->getEOLCoordinatorId();

        $request = EnrollmentPeriod::schedule($command->open, $command->close, $command->strm);

        $this->repository->persist($request);
    }
}

Pros

  • maps closely to the process
  • flexible to changes in process
  • meaning of events can change without altering history
  • scholarships; able to back up all events to roll over a new year.
    Didn't need to preserve full database.

Cons

  • a lot of classes
  • more design patterns to adjust to (complicated)

Links for further reading

Thank You!