Event Sourcing a Small Library

Emily Stamey
@elstamey

developer
  • PHP developer
  • Organizer of Triangle PHP
  • Director WWCode Raleigh-Durham
  • Work at InQuest
  •  

    My kid and all of his ideas

    The Librarian
    the library

    Requirements

     

    • Check out books
    • Reserve books
    • Renew books

    The Librarian's Process

     

    • Check in and Check out Books
    • View a List of Books I Own
    • See who has checked out books

    Crud

    <?php
    
    namespace Library\Http\Controllers\Api\V1;
    
    use Illuminate\Http\Request;
    use Library\Book;
    use Library\Http\Controllers\Controller;
    
    class BooksController extends Controller
    {
        public function index()
        {
            return Book::all();
        }
    
        public function show($id)
        {
            return Book::findOrFail($id);
        }
    
        public function update(Request $request, $id)
        {
            $Book = Book::findOrFail($id);
            $Book->update($request->all());
    
            return $Book;
        }
    
        public function store(Request $request)
        {
            $Book = Book::create($request->all());
            return $Book;
        }
    
        public function destroy($id)
        {
            $Book = Book::findOrFail($id);
            $Book->delete();
            return '';
        }
    }
    

    Book Check-in/out

     

    • Modify update method to change status
    • Using a status table to show if the book is in or out
    • Any of this would be fine

    Pause for Warning

    Switch from CRUD to ES is tough

    #NotAllApplications need to be Event-Sourced

    Learning Objectives:

     

    • What is Event Sourcing
      • Rules to Follow
      • Data Structures: Events, Projections, Read Models, CQRS(briefly)
    • How our code will change
    • Why would we want to use Event Sourcing

    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

    Events and Listeners

    An Event represented as a diamond and a Listener represented as an ear

    An Event


    What happened?
    BookWasCheckedOut

    What do I need to remember about it?
    (book, patron, date)

    Attributes

     

    Save only what you need to preserve, The rest can be looked up

    (book id, patron id, date)

    Rules to Follow

    • Usually named as past-tense verbs
    • RARELY changed
    • Never deleted
    • Has attributes that are values
      • not model, object, collection, or aggregate root

    3 symbols representing events of a deposit, a correction, and a deposit

    Don't store objects

    objects can change

    If We Stored it in an Event...

    Event With Object

    Events are not Bionic

    Events and Bionic Cat

    Events rarely 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 thing that happened

    One event with one listener handling the event

    One event with two listeners handling the event differently, one is thrown away

    One event with two listeners handling the event differently

    Reasons to use Events

    • State transitions are important
    • We need an audit log, proof of the state we are currently in
    • The history of what happened is more important than the current state
    • Events are replayable if behavior in your application changes

    Event Class

    
    <?php
    
    namespace Library\Events;
    
    use Library\Support\Event;
    
    class BookWasCheckedOut
    {
    
        /**
         * @var DateTime
         */
        public $checkoutDate;
    
        /**
         * @var int
         */
        public $patronId;
    
        /**
         * @var int
         */
        public $bookId;
    
        public function __construct(DateTime $checkoutDate, PatronId $patronId, BookId $bookId)
        {
    
            $this->checkoutDate = $checkoutDate;
            $this->patronId = $patronId;
            $this->bookId = $bookId;
        }
    
        /**
        * @return array
        */
        public function serialize()
        {
            return [
                'checkout_date' => $this->$checkoutDate->toString(),
                'patron_id' => $this->patronId->toNative(),
                'book_id' => $this->bookId->toNative(),
            ];
        }
    
        /**
        * @param array $data
        *
        * @return static
        */
        public static function deserialize($data)
        {
            return new BookWasCheckedOut(
                DateTime::createFromFormat('j-M-Y', $data['checkout_date']),
                PatronId::fromNative($data['patron_id']),
                BookId::fromNative($data['book_id'])
            );
        }
    }
    

    Connecting the events

     

    • An event is created only after validation
      • directly in a controller 'checkout' method
      • using a Check Out Book Command and Handler

    Domain Message

    Event Store

    • Domain-specific database
    • Based on a Publish-Subscribe message pattern

    EventStore

    Projector

    <?php
    
    namespace Library\ReadModel;
    
    use Library\Events\BookWasCheckedIn;
    use Library\Events\BookAddedToBookshelf;
    use App\Support\ReadModel\Replayable;
    use App\Support\ReadModel\SimpleProjector;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Database\Connection;
    
    class BookshelfProjector extends SimpleProjector implements Replayable
    {
    
        /**
         * @var Connection
         */
        private $connection;
    
        /**
         * @var string table we're playing events into
         */
        private $table = 'proj_bookshelf';
    
        public function __construct(Connection $connection)
        {
            $this->connection = $connection;
        }
    
        public function beforeReplay()
        {
            $builder = $this->connection->getSchemaBuilder();
    
            $builder->dropIfExists('proj_bookshelf');
            $builder->create('proj_bookshelf_tmp', function (Blueprint $schema) {
                $schema->string('book_id');
                $schema->string('book_title');
                $schema->string('book_author');
                $schema->string('status');
                $schema->string('checkout_date');
                $schema->string('patron_id');
    
                $schema->primary('book_id');
            });
    
            $this->table = 'proj_bookshelf_tmp';
        }
    
        public function afterReplay()
        {
            $builder = $this->connection->getSchemaBuilder();
    
            $builder->dropIfExists('proj_bookshelf');
            $builder->rename('proj_bookshelf_tmp', 'proj_bookshelf');
    
            $this->table = 'proj_bookshelf';
        }
    
        /**
        * @param BookWasCheckedOut $event
        */
        public function applyBookWasCheckedOut(BookWasCheckedOut $event)
        {
            $bookshelfItem = BookshelfItem::where('id', $event->bookId);
    
            $book->status = 'Checked Out';
            $book->checkout_date = $event->checkoutDate;
            $book->patron_id = $event->patronId;
    
            $book->save();
        }
    
        /**
        * @param BookAddedToBookshelf $event
        */
        public function applyBookAddedToBookshelf(BookAddedToBookshelf $event)
        {
            $bookshelfItem = new BookshelfItem();
            $bookshelfItem->setTable($this->table);
    
            $bookshelfItem->bookId = $event->bookId;
            $bookshelfItem->bookTitle = $event->bookTitle;
            $bookshelfItem->bookAuthor = $event->bookAuthor;
            $bookshelfItem->status = 'on shelf';
    
            $bookshelfItem->save();
        }
    
    }
    

    A set of event handlers that work together to build and maintain a table to be accessed by the read model.

    Read Model

    Read Model table with highlighted rows and queries

    Read Model

    <?php
    
    namespace Library\ReadModel;
    
    
    use Carbon\Carbon;
    use Illuminate\Database\Eloquent\Model;
    
    /**
    * @codeCoverageIgnore
    */
    class Bookshelf extends Model
    {
        protected  $table = 'proj_bookshelf';
        public $incrementing = false;
        public $timestamps = false;
    
        public static function lookupLoansFor($patronId)
        {
            return static::where('patron_id', $patronId)->get();
        }
    
        public function lookupAvailableBooks()
        {
            return static::where('status', 'on shelf')->get();
        }
    
        public function lookupOverdueBooks()
        {
            return static::where('checkout_date', '<', date('Y-m-d', strtotime('-7 days')))->get();
        }
    }
    

    CRUD: update

    public function update(Request $request, $id)
    {
        // our default update method
    	// validate inputs
        $Book = Book::findOrFail($id);
        $Book->update($request->all());
    
        return $Book;
    }
    

    CRUD Checkout

     

    
    public function checkOutBook(Request $request, $id)
    {
        // altered the update method
    	// validate inputs
        $Book = Book::findOrFail($id);
        $Book->update('status' => 'checked out',
                        'patron' => $request->patronId);
    
        return $Book;
    }
    

     

    cat climbing into a trash pail but doesn't quite fit

    CRUD Checkout

     

    
    public function checkOutBook(Request $request, $id)
    {
        // altered the update method
        // validate book can be checked out
    
        $event = new BookWasCheckedOut($request->bookId,
    				$request->patronId,
    				time());
    
    }
    

    CQRS

    Command and Query Response Segregation

    • Command is any method that mutates state
    • Query is any method that returns a value
    • These methods become part of Services
    • When parts of your application no longer fit a CRUD model
    • Should only be used on specific portions of a system, not the system as a whole

    CRUD to CQRS

    public function update(Request $request)
    {
        // $request has book id, patron id
    
        try {
    
            $command = new CheckOutBook($request->bookId, $request->patronId);
    
            $this->bookLendingService->handleCheckOutBook($command);
    
        } catch (InvalidUserException $e) {
            return response()->json("Not authorized to request enrollment.", Response::HTTP_FORBIDDEN);
        } catch (BookUnavailableException $e) {
           return response()->json("Book was not available to be checked out", 400);
    
    
        return $Book;
    }
    
    <------ dynamic command handler  ------>
    
    private function handle(Command $command)
    {
        $method = $this->getHandleMethod($command);
    
        if (! method_exists($this, $method)) {
            return;
        }
    
        $this->$method($command);
    }
    
    private function getHandleMethod(Command $command)
    {
        return 'handle' . class_basename($command);
    }
    
    <-------- handle check out book command --------->
    
    public function handleCheckoutOutBook(CheckOutBook $command)
    {
        $book = Book::findOrFail($command->bookId);
        $patron = Patron::findOrFail($command->patronId);
    
        if (!$book->isAvailable()) {
            throw new BookUnavailableException();
        }
    
        if (!$patron->isAuthorized()) {
            throw new InvalidUserException();
        }
    
        //record the event
        $this->record(
            new BookWasCheckedOut(date("Y-m-d H:i:s"),
                $patron->getId(),
                $book->getId())
        );
    
    
    }
    

    Command Handler

    A command handler receives a command and brokers a result from the appropriate aggregate. "A result" is either a successful application of the command, or an exception.

    should affect one and only one aggregate

    Command Handler

    1. Validate the command on its own merits.
    2. Validate the command on the current state of the aggregate.
    3. If validation is successful, create an event(s)
    4. Attempt to persist the new events. If there's a concurrency conflict during this step, retry or exit.

    Optimize

    • all commands go into a WriteService
    • all queries go into a ReadService
    • to optimize your application for reads and writes
    • Separate the load from reads and writes allowing you to scale each independently.
      • If your application sees a big disparity between reads and writes this is very handy.
    • Uses a separate model for all queries

    Task-based UI

    • Track what the user is doing and push forward commands representing the intent of the user
    • CQRS does not require a task based UI (DDD does)
    • CQRS used in a CRUD interface, makes creating separated data models harder

     

     

     

     

    Library

    • May not be a purely task-based UI
    • I don't need to optimize for load

    Book Form to Request Hold

    BookLendingService

    • CheckOutBook
    • CheckInBook
    • RequestBook

    Flexibility gained long term

    • Aren't locked into the current interpretation of events
    • Can track the events and build more views of the data and add functionality later
    • Financial reports, can show budget balance on any given day in the past, prove how you got the current balance
    • Could display everyone who checked out a book, and style it like old cards in the book pockets

    Book Back Pocket with names of people who checked it out

    Adding Event Sourcing to Your Legacy App

    Thank you!