I'm a PHP developer
Organizer of Triangle PHP and Director of WWCode Raleigh/Durham
I work at a company that builds tools for network security threat assessment
A lot of our systems were built to replace paper processes
They often closely map to this physical form.
Status labels are like a rubber stamp
Status doesn't always communicate why or what happened
Piles indicate status of the form
Status is a reflection of something that happened
There is ONE of each status + reasons/details
Events can record what happened
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.
state transitions 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
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
There are many classes involved:
<?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);
}
}
A domain specific database
A functional database based on a publish-subscribe messages pattern
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" ;
}
}
{
"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
}
}
}
<?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->status = 'rejected';
$enrollmentRequest->save();
}
}
<?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');
}
}
<?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'])
);
}
}
<?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);
}
}
Emily Stamey @elstamey
Joind.in: https://joind.in/talk/a0c6e